lumitrace 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/runv/index.html CHANGED
@@ -4,9 +4,9 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>LumiTrace Playground</title>
7
- <meta name="description" content="Run Ruby in the browser and see traced values inline with lumitrace 0.4.1.">
7
+ <meta name="description" content="Run Ruby in the browser and see traced values inline with lumitrace 0.6.0.">
8
8
  <meta property="og:title" content="LumiTrace Playground">
9
- <meta property="og:description" content="Run Ruby in the browser and see traced values inline with lumitrace 0.4.1.">
9
+ <meta property="og:description" content="Run Ruby in the browser and see traced values inline with lumitrace 0.6.0.">
10
10
  <meta property="og:type" content="website">
11
11
  <meta property="og:url" content="https://github.com/ko1/lumitrace/tree/master/runv">
12
12
  <meta property="og:see_also" content="https://github.com/ko1/lumitrace/tree/master/runv">
@@ -290,7 +290,7 @@
290
290
  <header>
291
291
  <div>
292
292
  <h1>LumiTrace Playground</h1>
293
- <p class="sub">Run Ruby in the browser and see traced values inline with lumitrace 0.4.1.</p>
293
+ <p class="sub">Run Ruby in the browser and see traced values inline with lumitrace 0.6.0.</p>
294
294
  </div>
295
295
  </header>
296
296
 
@@ -448,6 +448,8 @@ module Lumitrace
448
448
  end
449
449
 
450
450
  module RecordInstrument
451
+ IDENTIFIER_METHOD_NAME_RE = /\A[a-z_]\w*[!?=]?\z/.freeze
452
+
451
453
  SKIP_NODE_CLASSES = [
452
454
  Prism::DefNode,
453
455
  Prism::ClassNode,
@@ -488,7 +490,7 @@ module RecordInstrument
488
490
  Prism::GlobalVariableReadNode
489
491
  ].freeze
490
492
 
491
- def self.instrument_source(src, ranges, file_label: nil, record_method: "Lumitrace::R")
493
+ def self.instrument_source(src, ranges, file_label: nil, record_method: "::Lumitrace::R")
492
494
  file_label ||= "(unknown)"
493
495
  ranges = normalize_ranges(ranges)
494
496
 
@@ -540,7 +542,7 @@ module RecordInstrument
540
542
  end
541
543
  end
542
544
 
543
- node.child_nodes.each { |child| stack << [child, node] }
545
+ instrumentable_child_nodes(node).each { |child| stack << [child, node] }
544
546
  end
545
547
  locs
546
548
  end
@@ -577,7 +579,7 @@ module RecordInstrument
577
579
  end
578
580
  end
579
581
 
580
- node.child_nodes.each { |child| stack << [child, node] }
582
+ instrumentable_child_nodes(node).each { |child| stack << [child, node] }
581
583
  end
582
584
 
583
585
  inserts
@@ -618,6 +620,8 @@ module RecordInstrument
618
620
  def self.wrap_expr?(node, parent = nil)
619
621
  return false unless node.respond_to?(:location)
620
622
  return false if literal_value_node?(node)
623
+ return false if command_style_call_node?(node)
624
+ return false if parent.is_a?(Prism::DefinedNode)
621
625
  if parent.is_a?(Prism::AliasGlobalVariableNode) || parent.is_a?(Prism::AliasMethodNode)
622
626
  return false
623
627
  end
@@ -637,6 +641,27 @@ module RecordInstrument
637
641
  WRAP_NODE_CLASSES.include?(node.class)
638
642
  end
639
643
 
644
+ def self.command_style_call_node?(node)
645
+ return false unless node.is_a?(Prism::CallNode)
646
+ return false unless node.respond_to?(:arguments) && node.arguments
647
+ return false if node.respond_to?(:opening_loc) && node.opening_loc
648
+
649
+ name = node.respond_to?(:name) ? node.name : nil
650
+ return false unless name
651
+
652
+ IDENTIFIER_METHOD_NAME_RE.match?(name.to_s)
653
+ end
654
+
655
+ def self.instrumentable_child_nodes(node)
656
+ return [] unless node
657
+ return [] if node.is_a?(Prism::DefinedNode)
658
+ if command_style_call_node?(node) && node.respond_to?(:block) && node.block
659
+ [node.block]
660
+ else
661
+ node.child_nodes
662
+ end
663
+ end
664
+
640
665
  def self.expr_location(node)
641
666
  loc = node.location
642
667
  return {
@@ -782,22 +807,31 @@ module RecordInstrument
782
807
 
783
808
  def self.events_from_ids
784
809
  out = []
785
- @events_by_id.each_with_index do |e, id|
786
- next unless e
787
- loc = @loc_by_id[id]
810
+ summary_cache = {}
811
+ @loc_by_id.each_with_index do |loc, id|
788
812
  next unless loc
813
+
814
+ e = @events_by_id[id]
789
815
  case collect_mode
790
816
  when :history
791
- raw_values = values_from_ring(e)
792
- all_types = history_type_set(e)
793
- if all_types.nil? || all_types.empty?
794
- all_types = {}
795
- raw_values.each do |v|
796
- t = value_type_name(v)
797
- all_types[t] = (all_types[t] || 0) + 1
817
+ if e
818
+ raw_values = values_from_ring(e)
819
+ all_types = history_type_set(e)
820
+ if all_types.nil? || all_types.empty?
821
+ all_types = {}
822
+ raw_values.each do |v|
823
+ t = value_type_name(v)
824
+ all_types[t] = (all_types[t] || 0) + 1
825
+ end
798
826
  end
827
+ max = history_ring_size(e)
828
+ total = e[max + 1]
829
+ else
830
+ raw_values = []
831
+ all_types = {}
832
+ total = 0
799
833
  end
800
- max = history_ring_size(e)
834
+
801
835
  out << {
802
836
  file: loc[:file],
803
837
  start_line: loc[:start_line],
@@ -806,9 +840,20 @@ module RecordInstrument
806
840
  end_col: loc[:end_col],
807
841
  kind: loc[:kind].to_s,
808
842
  name: loc[:name],
809
- sampled_values: raw_values.map { |v| summarize_value(v, type: value_type_name(v)) },
843
+ sampled_values: raw_values.map do |v|
844
+ key = v.__id__
845
+ cached = summary_cache[key]
846
+ if cached
847
+ cached.dup
848
+ else
849
+ type = value_type_name(v)
850
+ summary = summarize_value(v, type: type)
851
+ summary_cache[key] = summary
852
+ summary.dup
853
+ end
854
+ end,
810
855
  types: sorted_type_counts(all_types),
811
- total: e[max + 1]
856
+ total: total
812
857
  }
813
858
  when :types
814
859
  out << {
@@ -819,12 +864,12 @@ module RecordInstrument
819
864
  end_col: loc[:end_col],
820
865
  kind: loc[:kind].to_s,
821
866
  name: loc[:name],
822
- types: sorted_type_counts(e[:types]),
823
- total: e[:total]
867
+ types: sorted_type_counts(e ? e[:types] : nil),
868
+ total: e ? e[:total] : 0
824
869
  }
825
870
  else # :last
826
- last_raw = e[:last_value]
827
- last_type = value_type_name(last_raw)
871
+ last_raw = e && e[:last_value]
872
+ last_type = value_type_name(last_raw) if e
828
873
  out << {
829
874
  file: loc[:file],
830
875
  start_line: loc[:start_line],
@@ -833,9 +878,21 @@ module RecordInstrument
833
878
  end_col: loc[:end_col],
834
879
  kind: loc[:kind].to_s,
835
880
  name: loc[:name],
836
- last_value: summarize_value(last_raw, type: last_type),
837
- types: sorted_type_counts(e[:types]),
838
- total: e[:total]
881
+ last_value: if e
882
+ key = last_raw.__id__
883
+ cached = summary_cache[key]
884
+ if cached
885
+ cached.dup
886
+ else
887
+ summary = summarize_value(last_raw, type: last_type)
888
+ summary_cache[key] = summary
889
+ summary.dup
890
+ end
891
+ else
892
+ nil
893
+ end,
894
+ types: sorted_type_counts(e ? e[:types] : nil),
895
+ total: e ? e[:total] : 0
839
896
  }
840
897
  end
841
898
  end
@@ -879,10 +936,42 @@ module RecordInstrument
879
936
 
880
937
  def self.dump_events_json(events, path = nil)
881
938
  path ||= File.expand_path("lumitrace_recorded.json", Dir.pwd)
882
- File.write(path, JSON.dump(events), perm: 0o600)
939
+ payload = {
940
+ version: 1,
941
+ events: events,
942
+ coverage: compute_coverage(events)
943
+ }
944
+ File.write(path, JSON.dump(payload), perm: 0o600)
883
945
  path
884
946
  end
885
947
 
948
+ def self.compute_coverage(events)
949
+ by_file = {}
950
+ events.each do |e|
951
+ file = e[:file] || e["file"]
952
+ next unless file
953
+ total = e[:total] || e["total"] || 0
954
+ start_line = e[:start_line] || e["start_line"]
955
+ next unless start_line
956
+
957
+ entry = (by_file[file] ||= { lines: {}, covered_lines: {} })
958
+ entry[:lines][start_line] = true
959
+ entry[:covered_lines][start_line] = true if total > 0
960
+ end
961
+
962
+ by_file.map do |file, entry|
963
+ total_lines = entry[:lines].size
964
+ covered_lines = entry[:covered_lines].size
965
+ pct = total_lines > 0 ? (covered_lines * 100.0 / total_lines).round(1) : 0.0
966
+ {
967
+ file: file,
968
+ total_lines: total_lines,
969
+ covered_lines: covered_lines,
970
+ coverage_percent: pct
971
+ }
972
+ end.sort_by { |e| e[:file] }
973
+ end
974
+
886
975
  def self.load_events_json(path)
887
976
  JSON.parse(File.read(path))
888
977
  end
@@ -1070,25 +1159,36 @@ module RecordInstrument
1070
1159
  events_from_ids
1071
1160
  end
1072
1161
 
1162
+ KERNEL_INSPECT = ::Kernel.instance_method(:inspect)
1163
+
1164
+ def self.safe_inspect(v)
1165
+ v.inspect
1166
+ rescue ::NoMethodError
1167
+ KERNEL_INSPECT.bind_call(v)
1168
+ end
1169
+
1073
1170
  def self.safe_value(v)
1074
1171
  case v
1075
1172
  when Numeric, TrueClass, FalseClass, NilClass
1076
1173
  v
1077
1174
  else
1078
- s = v.inspect
1175
+ s = safe_inspect(v)
1079
1176
  s.bytesize > 1000 ? s[0, 1000] + "..." : s
1080
1177
  end
1081
1178
  end
1082
1179
 
1180
+ KERNEL_CLASS = ::Kernel.instance_method(:class)
1181
+
1083
1182
  def self.value_type_name(v)
1084
- name = v.class.name
1085
- name && !name.empty? ? name : v.class.to_s
1183
+ klass = KERNEL_CLASS.bind_call(v)
1184
+ name = klass.name
1185
+ name && !name.empty? ? name : klass.to_s
1086
1186
  end
1087
1187
 
1088
1188
  def self.summarize_value(v, type: nil)
1089
1189
  type ||= value_type_name(v)
1090
1190
  preview_limit = 120
1091
- inspected = v.inspect
1191
+ inspected = safe_inspect(v)
1092
1192
  if inspected.length > preview_limit
1093
1193
  {
1094
1194
  type: type,
@@ -1292,6 +1392,20 @@ require "json"
1292
1392
 
1293
1393
  module Lumitrace
1294
1394
  module GenerateResultedHtml
1395
+ RENDERER_JS_PATH = File.expand_path("generate_resulted_html_renderer.js", __dir__)
1396
+
1397
+ def self.monotonic_now
1398
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1399
+ end
1400
+
1401
+ def self.time_step(label, logger = nil)
1402
+ start = monotonic_now
1403
+ result = yield
1404
+ elapsed_ms = (monotonic_now - start) * 1000.0
1405
+ logger&.call(format("html render: %s %.1fms", label, elapsed_ms))
1406
+ result
1407
+ end
1408
+
1295
1409
  def self.render(source_path, events_path, ranges: nil, collect_mode: nil, max_samples: nil)
1296
1410
  unless File.exist?(events_path)
1297
1411
  abort "missing #{events_path}"
@@ -1300,51 +1414,972 @@ module GenerateResultedHtml
1300
1414
  abort "missing #{source_path}"
1301
1415
  end
1302
1416
 
1303
- raw_events = JSON.parse(File.read(events_path))
1417
+ raw = JSON.parse(File.read(events_path))
1418
+ raw_events = raw.is_a?(Hash) && raw.key?("events") ? raw["events"] : raw
1419
+ src = File.read(source_path)
1304
1420
  mode_info = resolve_mode_info(raw_events, collect_mode: collect_mode, max_samples: max_samples)
1305
- events = normalize_events(raw_events)
1306
- events = add_missing_events(events, File.read(source_path), source_path, ranges)
1421
+ normalized_ranges = normalize_ranges(ranges)
1422
+ events = normalize_events(raw_events).select { |e| e[:file] == source_path }
1423
+
1424
+ payload = build_html_payload(
1425
+ mode_info: mode_info,
1426
+ files: [
1427
+ build_html_payload_file(
1428
+ path: source_path,
1429
+ display_path: File.basename(source_path),
1430
+ source: src,
1431
+ ranges: normalized_ranges,
1432
+ trace_events: events
1433
+ )
1434
+ ]
1435
+ )
1307
1436
 
1308
- src = File.read(source_path)
1309
- src_lines = src.lines
1310
- ranges = normalize_ranges(ranges)
1311
- expected_by_line, executed_by_line = line_stats(src, ranges, events, source_path)
1437
+ render_payload_html(payload)
1438
+ end
1312
1439
 
1313
- html_lines = []
1314
- prev_lineno = nil
1315
- first_lineno = nil
1316
- last_lineno = nil
1317
- src_lines.each_with_index do |line, idx|
1318
- lineno = idx + 1
1319
- next if ranges && !line_in_ranges?(lineno, ranges)
1320
- first_lineno ||= lineno
1321
- if prev_lineno && lineno > prev_lineno + 1
1322
- html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
1323
- end
1324
- line_text = line.chomp
1325
- evs = aggregate_events_for_line(events, lineno, line_text.length)
1326
- expected = expected_by_line[lineno]
1327
- executed = executed_by_line[lineno]
1328
- line_class = line_class_for(expected, executed)
1329
- if expected > 0 && executed == 0
1330
- evs.each { |e| e[:suppress_miss] = true }
1331
- end
1332
- if evs.empty?
1333
- html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{esc(line_text)}</span>\n"
1334
- else
1335
- rendered = render_line_with_events(line_text, evs)
1336
- html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{rendered}</span>\n"
1337
- end
1338
- prev_lineno = lineno
1339
- last_lineno = lineno
1440
+ def self.esc(s)
1441
+ s.to_s
1442
+ .gsub("&", "&amp;")
1443
+ .gsub("<", "&lt;")
1444
+ .gsub(">", "&gt;")
1445
+ .gsub('"', "&quot;")
1446
+ end
1447
+
1448
+ def self.build_html_payload(mode_info:, files:, command_text: nil)
1449
+ meta = {
1450
+ mode: mode_info[:mode],
1451
+ mode_text: mode_info[:text],
1452
+ max_samples: mode_info[:max_samples]
1453
+ }
1454
+ meta[:command] = command_text if command_text && !command_text.to_s.empty?
1455
+ {
1456
+ version: 1,
1457
+ meta: meta,
1458
+ files: files
1459
+ }
1460
+ end
1461
+
1462
+ def self.build_html_payload_file(path:, display_path:, source:, ranges:, trace_events:, logger: nil)
1463
+ sort_start = logger ? monotonic_now : nil
1464
+ sorted_events = Array(trace_events).sort_by do |e|
1465
+ [e[:start_line].to_i, e[:start_col].to_i, e[:end_line].to_i, e[:end_col].to_i]
1340
1466
  end
1341
- if first_lineno && first_lineno > 1
1342
- html_lines.unshift("<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n")
1467
+ sort_ms = sort_start ? (monotonic_now - sort_start) * 1000.0 : nil
1468
+
1469
+ map_start = logger ? monotonic_now : nil
1470
+ trace_payload = sorted_events.map { |e| event_to_html_trace_payload(e) }
1471
+ map_ms = map_start ? (monotonic_now - map_start) * 1000.0 : nil
1472
+ if logger
1473
+ logger.call(format("html render: payload_file %s sort=%.1fms map=%.1fms events=%d", display_path, sort_ms, map_ms, sorted_events.length))
1343
1474
  end
1344
- if last_lineno && last_lineno < src_lines.length
1345
- html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
1475
+
1476
+ {
1477
+ path: path,
1478
+ display_path: display_path,
1479
+ source: source,
1480
+ ranges: ranges,
1481
+ trace: trace_payload
1482
+ }
1483
+ end
1484
+
1485
+ def self.event_to_html_trace_payload(e)
1486
+ sampled_values = e[:sampled_values]
1487
+ if (sampled_values.nil? || sampled_values.empty?) && e[:last_value]
1488
+ sampled_values = [e[:last_value]]
1346
1489
  end
1490
+ {
1491
+ location: [
1492
+ e[:start_line].to_i,
1493
+ e[:start_col].to_i,
1494
+ e[:end_line].to_i,
1495
+ e[:end_col].to_i
1496
+ ],
1497
+ kind: (e[:kind] || "expr").to_s,
1498
+ name: e[:name],
1499
+ sampled_values: sampled_values || [],
1500
+ types: sorted_type_counts(e[:types]),
1501
+ total: e[:total].to_i
1502
+ }
1503
+ end
1504
+
1505
+ def self.payload_json_for_script(payload)
1506
+ JSON.generate(payload)
1507
+ .gsub("</", "<\\/")
1508
+ .gsub("\u2028", "\\u2028")
1509
+ .gsub("\u2029", "\\u2029")
1510
+ end
1511
+
1512
+ def self.html_renderer_js
1513
+ @html_renderer_js ||= '(function() {
1514
+ const payloadEl = document.getElementById("lumitrace-payload");
1515
+ const app = document.getElementById("lumitrace-app");
1516
+ if (!payloadEl || !app) return;
1517
+
1518
+ const SL = 0;
1519
+ const SC = 1;
1520
+ const EL = 2;
1521
+ const EC = 3;
1522
+
1523
+ function escHtml(s) {
1524
+ return String(s)
1525
+ .replace(/&/g, "&amp;")
1526
+ .replace(/</g, "&lt;")
1527
+ .replace(/>/g, "&gt;")
1528
+ .replace(/\\"/g, "&quot;");
1529
+ }
1530
+
1531
+ function normalizeTypeCounts(types) {
1532
+ if (!types) return {};
1533
+ if (Array.isArray(types)) {
1534
+ const out = {};
1535
+ for (const t of types) {
1536
+ const key = String(t || "");
1537
+ if (!key) continue;
1538
+ out[key] = (out[key] || 0) + 1;
1539
+ }
1540
+ return out;
1541
+ }
1542
+ if (typeof types === "object") {
1543
+ const out = {};
1544
+ for (const [k, v] of Object.entries(types)) {
1545
+ const key = String(k || "");
1546
+ if (!key) continue;
1547
+ let count = Number(v) || 0;
1548
+ if (count <= 0) count = 1;
1549
+ out[key] = (out[key] || 0) + count;
1550
+ }
1551
+ return out;
1552
+ }
1553
+ const key = String(types || "");
1554
+ return key ? { [key]: 1 } : {};
1555
+ }
1556
+
1557
+ function typeListText(types, onlyIfMultiple) {
1558
+ const counts = normalizeTypeCounts(types);
1559
+ const entries = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
1560
+ if (onlyIfMultiple && entries.length <= 1) return null;
1561
+ if (entries.length === 0) return "(no types)";
1562
+ return "types: " + entries.map(([k, v]) => `${k}(${v})`).join(", ");
1563
+ }
1564
+
1565
+ function valueTypeName(v) {
1566
+ if (v === null) return "NilClass";
1567
+ if (Array.isArray(v)) return "Array";
1568
+ switch (typeof v) {
1569
+ case "number": return Number.isInteger(v) ? "Integer" : "Float";
1570
+ case "string": return "String";
1571
+ case "boolean": return "Boolean";
1572
+ case "undefined": return "NilClass";
1573
+ case "object": return "Object";
1574
+ default: return typeof v;
1575
+ }
1576
+ }
1577
+
1578
+ function formatValue(v, type) {
1579
+ const value = v == null ? "nil" : String(v);
1580
+ return `${value} (${type || valueTypeName(v)})`;
1581
+ }
1582
+
1583
+ function lastValueToPair(lastValue) {
1584
+ if (lastValue == null) return [null, null];
1585
+ if (typeof lastValue !== "object" || Array.isArray(lastValue)) return [lastValue, null];
1586
+ const type = lastValue.type || null;
1587
+ if (Object.prototype.hasOwnProperty.call(lastValue, "value")) return [lastValue.value, type];
1588
+ if (Object.prototype.hasOwnProperty.call(lastValue, "preview")) return [lastValue.preview, type];
1589
+ if (Object.prototype.hasOwnProperty.call(lastValue, "inspect")) return [lastValue.inspect, type];
1590
+ return [JSON.stringify(lastValue), type];
1591
+ }
1592
+
1593
+ function summarizeValues(values, total, allTypes) {
1594
+ if (!values || values.length === 0) {
1595
+ const multi = typeListText(allTypes, false);
1596
+ return multi || "";
1597
+ }
1598
+
1599
+ const n = total == null ? values.length : Number(total);
1600
+ const lastVals = values.slice(-3);
1601
+ const firstIndex = n - lastVals.length + 1;
1602
+ const lines = [];
1603
+ const extra = n - lastVals.length;
1604
+ if (extra > 0) lines.push(`... (+${extra} more)`);
1605
+
1606
+ lastVals.forEach((v, i) => {
1607
+ const idx = firstIndex + i;
1608
+ const [valueText, typeText] = lastValueToPair(v);
1609
+ lines.push(`#${idx}: ${formatValue(valueText, typeText)}`);
1610
+ });
1611
+
1612
+ const multi = typeListText(allTypes, true);
1613
+ if (multi) lines.push(multi);
1614
+ return lines.join("\\n");
1615
+ }
1616
+
1617
+ function lineClassFor(expected, executed) {
1618
+ if (executed > 0) return " hit";
1619
+ if (expected > 0) return " miss";
1620
+ return "";
1621
+ }
1622
+
1623
+ function lineInRanges(line, ranges) {
1624
+ if (!ranges || ranges.length === 0) return true;
1625
+ return ranges.some((range) => {
1626
+ if (!Array.isArray(range) || range.length < 2) return false;
1627
+ const start = Number(range[0]);
1628
+ const end = Number(range[1]);
1629
+ return line >= start && line <= end;
1630
+ });
1631
+ }
1632
+
1633
+ function sourceLines(source) {
1634
+ const matches = String(source || "").match(/[^\\n]*\\n|[^\\n]+/g);
1635
+ if (!matches) return [];
1636
+ return matches.map((line) => line.endsWith("\\n") ? line.slice(0, -1) : line);
1637
+ }
1638
+
1639
+ function lineStatsForFile(trace) {
1640
+ const expectedByLine = Object.create(null);
1641
+ const executedByLine = Object.create(null);
1642
+ const seen = Object.create(null);
1643
+
1644
+ for (const event of trace || []) {
1645
+ const loc = event && event.location;
1646
+ if (!Array.isArray(loc) || loc.length < 4) continue;
1647
+ const sl = Number(loc[SL]);
1648
+ const el = Number(loc[EL]);
1649
+ if (!(sl > 0) || !(el > 0)) continue;
1650
+ const key = `${sl}:${loc[SC]}:${el}:${loc[EC]}`;
1651
+ if (seen[key]) continue;
1652
+ seen[key] = true;
1653
+ for (let line = sl; line <= el; line += 1) {
1654
+ expectedByLine[line] = (expectedByLine[line] || 0) + 1;
1655
+ if (Number(event.total) > 0) {
1656
+ executedByLine[line] = (executedByLine[line] || 0) + 1;
1657
+ }
1658
+ }
1659
+ }
1660
+
1661
+ return { expectedByLine, executedByLine };
1662
+ }
1663
+
1664
+ function expressionLineCoverageForTrace(trace) {
1665
+ const expectedLines = new Set();
1666
+ const executedLines = new Set();
1667
+ const seen = new Set();
1668
+
1669
+ for (const event of trace || []) {
1670
+ if (!event || event.kind === "arg") continue;
1671
+ const loc = event.location;
1672
+ if (!Array.isArray(loc) || loc.length < 4) continue;
1673
+
1674
+ const sl = Number(loc[SL]);
1675
+ const sc = Number(loc[SC]);
1676
+ const el = Number(loc[EL]);
1677
+ const ec = Number(loc[EC]);
1678
+ if (!(sl > 0) || !(el > 0)) continue;
1679
+
1680
+ const key = `${sl}:${sc}:${el}:${ec}`;
1681
+ if (seen.has(key)) continue;
1682
+ seen.add(key);
1683
+
1684
+ for (let line = sl; line <= el; line += 1) {
1685
+ expectedLines.add(line);
1686
+ if (Number(event.total) > 0) executedLines.add(line);
1687
+ }
1688
+ }
1689
+
1690
+ return {
1691
+ executed: executedLines.size,
1692
+ expected: expectedLines.size
1693
+ };
1694
+ }
1695
+
1696
+ function aggregateEventsForLine(trace, lineno, lineLen, fileIndex) {
1697
+ const buckets = new Map();
1698
+ const spans = [];
1699
+
1700
+ for (const event of trace || []) {
1701
+ const loc = event && event.location;
1702
+ if (!Array.isArray(loc) || loc.length < 4) continue;
1703
+ const sline = Number(loc[SL]);
1704
+ const scol = Number(loc[SC]);
1705
+ const eline = Number(loc[EL]);
1706
+ const ecol = Number(loc[EC]);
1707
+ if (lineno < sline || lineno > eline) continue;
1708
+
1709
+ let s;
1710
+ let t;
1711
+ let marker;
1712
+ if (sline === eline) {
1713
+ s = scol;
1714
+ t = ecol;
1715
+ marker = true;
1716
+ } else if (lineno === sline) {
1717
+ s = scol;
1718
+ t = lineLen;
1719
+ marker = false;
1720
+ } else if (lineno === eline) {
1721
+ s = 0;
1722
+ t = ecol;
1723
+ marker = true;
1724
+ } else {
1725
+ s = 0;
1726
+ t = lineLen;
1727
+ marker = false;
1728
+ }
1729
+
1730
+ if (!(t > s)) continue;
1731
+ spans.push({ start_col: s, end_col: t });
1732
+
1733
+ const keyId = `${fileIndex}:${sline}:${scol}:${eline}:${ecol}`;
1734
+ buckets.set(keyId, {
1735
+ key_id: keyId,
1736
+ start_col: s,
1737
+ end_col: t,
1738
+ marker,
1739
+ kind: event.kind || "expr",
1740
+ name: event.name || null,
1741
+ sampled_values: event.sampled_values || [],
1742
+ types: event.types || {},
1743
+ total: Number(event.total) || 0,
1744
+ suppress_miss: false
1745
+ });
1746
+ }
1747
+
1748
+ const out = Array.from(buckets.values());
1749
+ for (const b of out) {
1750
+ const depth = spans.filter((sp) => b.start_col >= sp.start_col && b.end_col <= sp.end_col).length;
1751
+ b.depth = Math.min(5, Math.max(1, depth));
1752
+ }
1753
+ out.sort((a, b) => a.start_col - b.start_col);
1754
+ return out;
1755
+ }
1756
+
1757
+ function renderLineHtml(lineText, events) {
1758
+ const opens = Object.create(null);
1759
+ const closes = Object.create(null);
1760
+
1761
+ for (const e of events) {
1762
+ const s = Number(e.start_col);
1763
+ const t = Number(e.end_col);
1764
+ if (!(t > s)) continue;
1765
+
1766
+ const values = e.sampled_values || [];
1767
+ const allTypes = e.types || {};
1768
+ const total = Number(e.total) || 0;
1769
+ const label = e.kind === "arg" && e.name ? `arg ${e.name}` : null;
1770
+
1771
+ let valueText;
1772
+ if (total === 0) {
1773
+ valueText = label ? `${label}: (not hit)` : "(not hit)";
1774
+ } else {
1775
+ const summary = summarizeValues(values, total, allTypes);
1776
+ if (label) {
1777
+ valueText = summary ? `${label}: ${summary}` : label;
1778
+ } else {
1779
+ valueText = summary;
1780
+ }
1781
+ }
1782
+
1783
+ const tooltipHtml = escHtml(valueText);
1784
+ const depthClass = `depth-${e.depth || 1}`;
1785
+ const missClass = total === 0 && !e.suppress_miss ? " miss" : "";
1786
+ const keyAttr = escHtml(e.key_id || "");
1787
+ const openTag = `<span class=\\"expr hit ${depthClass}${missClass}\\" data-key=\\"${keyAttr}\\">`;
1788
+
1789
+ let closeTag = "</span>";
1790
+ if (e.marker !== false) {
1791
+ let marker = "🔎";
1792
+ if (total === 0) marker = "∅";
1793
+ else if (e.kind === "arg") marker = "🧷";
1794
+
1795
+ let markerClass = "marker";
1796
+ if (total === 0 && !e.suppress_miss) markerClass = "marker miss";
1797
+ if (e.kind === "arg") markerClass += " arg";
1798
+ closeTag = `<span class=\\"${markerClass}\\" data-key=\\"${keyAttr}\\" aria-hidden=\\"true\\">${marker}<span class=\\"tooltip\\">${tooltipHtml}</span></span></span>`;
1799
+ }
1800
+
1801
+ const len = t - s;
1802
+ (opens[s] ||= []).push({ len, start: s, end: t, tag: openTag });
1803
+ (closes[t] ||= []).push({ len, start: s, end: t, tag: closeTag });
1804
+ }
1805
+
1806
+ const positions = Array.from(new Set([
1807
+ ...Object.keys(opens).map(Number),
1808
+ ...Object.keys(closes).map(Number)
1809
+ ])).sort((a, b) => a - b);
1810
+
1811
+ let out = "";
1812
+ let cursor = 0;
1813
+
1814
+ for (const pos of positions) {
1815
+ if (pos > cursor) out += escHtml(lineText.slice(cursor, pos));
1816
+ if (closes[pos]) {
1817
+ closes[pos]
1818
+ .slice()
1819
+ .sort((a, b) => (b.start - a.start) || (a.len - b.len))
1820
+ .forEach((c) => { out += c.tag; });
1821
+ }
1822
+ if (opens[pos]) {
1823
+ opens[pos]
1824
+ .slice()
1825
+ .sort((a, b) => b.end - a.end)
1826
+ .forEach((o) => { out += o.tag; });
1827
+ }
1828
+ cursor = pos;
1829
+ }
1830
+
1831
+ if (cursor < lineText.length) out += escHtml(lineText.slice(cursor));
1832
+ return out;
1833
+ }
1834
+
1835
+ function buildLineNode(lineno, lineText, lineClass, innerHtml, options) {
1836
+ const opts = options || {};
1837
+ const line = document.createElement("span");
1838
+ line.className = `line${lineClass}`;
1839
+ line.dataset.line = String(lineno);
1840
+ line.id = `line-${lineno}`;
1841
+
1842
+ const ln = opts.lineHref ? document.createElement("a") : document.createElement("span");
1843
+ ln.className = `ln${opts.lineHref ? " ln-link" : ""}`;
1844
+ ln.textContent = String(lineno);
1845
+ if (opts.lineHref) {
1846
+ ln.href = opts.lineHref(lineno);
1847
+ ln.title = `Link to line ${lineno}`;
1848
+ ln.addEventListener("click", (event) => {
1849
+ if (!opts.onLineClick) return;
1850
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
1851
+ event.preventDefault();
1852
+ opts.onLineClick(lineno);
1853
+ });
1854
+ }
1855
+ line.appendChild(ln);
1856
+ line.appendChild(document.createTextNode(" "));
1857
+
1858
+ if (innerHtml == null) {
1859
+ line.appendChild(document.createTextNode(lineText));
1860
+ } else {
1861
+ const wrapper = document.createElement("span");
1862
+ wrapper.innerHTML = innerHtml;
1863
+ while (wrapper.firstChild) line.appendChild(wrapper.firstChild);
1864
+ }
1865
+
1866
+ return line;
1867
+ }
1868
+
1869
+ function buildEllipsisNode() {
1870
+ const line = document.createElement("span");
1871
+ line.className = "line ellipsis";
1872
+ line.dataset.line = "...";
1873
+
1874
+ const ln = document.createElement("span");
1875
+ ln.className = "ln";
1876
+ ln.textContent = "...";
1877
+ line.appendChild(ln);
1878
+ return line;
1879
+ }
1880
+
1881
+ function renderFileSection(file, fileIndex, options) {
1882
+ const opts = options || {};
1883
+ const section = document.createElement("section");
1884
+ section.className = "file-section";
1885
+
1886
+ if (opts.includeTitle !== false) {
1887
+ const h2 = document.createElement("h2");
1888
+ h2.className = "file";
1889
+ h2.textContent = file.display_path || file.path || `file-${fileIndex + 1}`;
1890
+ section.appendChild(h2);
1891
+ }
1892
+
1893
+ const pre = document.createElement("pre");
1894
+ pre.className = "code";
1895
+ const code = document.createElement("code");
1896
+ pre.appendChild(code);
1897
+
1898
+ const lines = sourceLines(file.source || "");
1899
+ const ranges = Array.isArray(file.ranges) ? file.ranges : null;
1900
+ const trace = Array.isArray(file.trace) ? file.trace : [];
1901
+ const { expectedByLine, executedByLine } = lineStatsForFile(trace);
1902
+
1903
+ let prevLineno = null;
1904
+ let firstLineno = null;
1905
+ let lastLineno = null;
1906
+
1907
+ lines.forEach((lineText, idx) => {
1908
+ const lineno = idx + 1;
1909
+ if (!lineInRanges(lineno, ranges)) return;
1910
+ if (firstLineno == null) firstLineno = lineno;
1911
+ if (prevLineno != null && lineno > prevLineno + 1) {
1912
+ code.appendChild(buildEllipsisNode());
1913
+ }
1914
+
1915
+ const evs = aggregateEventsForLine(trace, lineno, lineText.length, fileIndex);
1916
+ const expected = expectedByLine[lineno] || 0;
1917
+ const executed = executedByLine[lineno] || 0;
1918
+ const lineClass = lineClassFor(expected, executed);
1919
+ if (expected > 0 && executed === 0) {
1920
+ evs.forEach((e) => { e.suppress_miss = true; });
1921
+ }
1922
+
1923
+ if (evs.length === 0) {
1924
+ code.appendChild(buildLineNode(lineno, lineText, lineClass, null, opts));
1925
+ } else {
1926
+ code.appendChild(buildLineNode(lineno, lineText, lineClass, renderLineHtml(lineText, evs), opts));
1927
+ }
1928
+
1929
+ prevLineno = lineno;
1930
+ lastLineno = lineno;
1931
+ });
1932
+
1933
+ if (firstLineno != null && firstLineno > 1) {
1934
+ code.insertBefore(buildEllipsisNode(), code.firstChild);
1935
+ }
1936
+ if (lastLineno != null && lastLineno < lines.length) {
1937
+ code.appendChild(buildEllipsisNode());
1938
+ }
1939
+
1940
+ section.appendChild(pre);
1941
+ return section;
1942
+ }
1943
+
1944
+ function bindMarkerHover(root) {
1945
+ root.querySelectorAll(".marker").forEach((marker) => {
1946
+ marker.addEventListener("mouseenter", () => {
1947
+ root.querySelectorAll(".expr.active").forEach((el) => el.classList.remove("active"));
1948
+ const key = marker.dataset.key;
1949
+ if (key) {
1950
+ root.querySelectorAll(`.expr[data-key=\\"${key}\\"]`).forEach((el) => el.classList.add("active"));
1951
+ } else {
1952
+ const expr = marker.closest(".expr");
1953
+ if (expr) expr.classList.add("active");
1954
+ }
1955
+ });
1956
+ marker.addEventListener("mouseleave", () => {
1957
+ const key = marker.dataset.key;
1958
+ if (key) {
1959
+ root.querySelectorAll(`.expr[data-key=\\"${key}\\"]`).forEach((el) => el.classList.remove("active"));
1960
+ } else {
1961
+ const expr = marker.closest(".expr");
1962
+ if (expr) expr.classList.remove("active");
1963
+ }
1964
+ });
1965
+ });
1966
+ }
1967
+
1968
+ function fileDisplayPath(file, idx) {
1969
+ return String((file && (file.display_path || file.path)) || `file-${idx + 1}`);
1970
+ }
1971
+
1972
+ function fileUrlKey(file, idx) {
1973
+ return fileDisplayPath(file, idx).replace(/\\\\/g, "/");
1974
+ }
1975
+
1976
+ function encodeHashFileKey(key) {
1977
+ return encodeURIComponent(String(key || "")).replace(/%2F/g, "/");
1978
+ }
1979
+
1980
+ function decodeHashFileKey(value) {
1981
+ try {
1982
+ return decodeURIComponent(String(value || ""));
1983
+ } catch (_e) {
1984
+ return String(value || "");
1985
+ }
1986
+ }
1987
+
1988
+ function parseHashStateFromLocation() {
1989
+ const raw = String(window.location.hash || "").replace(/^#/, "");
1990
+ if (!raw) return { fileKey: null, line: null };
1991
+
1992
+ if (!raw.includes("=")) {
1993
+ return { fileKey: decodeHashFileKey(raw), line: null };
1994
+ }
1995
+
1996
+ const params = new URLSearchParams(raw);
1997
+ const fileRaw = params.get("file");
1998
+ const lineRaw = params.get("line");
1999
+ const lineNum = lineRaw == null ? null : Number.parseInt(lineRaw, 10);
2000
+
2001
+ return {
2002
+ fileKey: fileRaw ? decodeHashFileKey(fileRaw) : null,
2003
+ line: Number.isFinite(lineNum) && lineNum > 0 ? lineNum : null
2004
+ };
2005
+ }
2006
+
2007
+ function buildHashForSelection(fileKey, line) {
2008
+ const parts = [];
2009
+ if (fileKey) parts.push(`file=${encodeHashFileKey(fileKey)}`);
2010
+ if (line && Number(line) > 0) parts.push(`line=${Number(line)}`);
2011
+ return `#${parts.join("&")}`;
2012
+ }
2013
+
2014
+ function setLocationHashSelection(fileKey, line) {
2015
+ const hash = buildHashForSelection(fileKey, line);
2016
+ if (window.location.hash === hash) return;
2017
+ if (window.history && typeof window.history.replaceState === "function") {
2018
+ try {
2019
+ window.history.replaceState(null, "", hash);
2020
+ return;
2021
+ } catch (_e) {
2022
+ // `about:srcdoc` (runv preview) can reject replaceState with a file:// hash URL.
2023
+ }
2024
+ }
2025
+ try {
2026
+ window.location.hash = hash;
2027
+ } catch (_e) {
2028
+ // Ignore hash update failures in restricted embedding contexts.
2029
+ }
2030
+ }
2031
+
2032
+ function buildFileTree(files) {
2033
+ const root = { dirs: new Map(), files: [] };
2034
+
2035
+ files.forEach((file, idx) => {
2036
+ const key = fileUrlKey(file, idx);
2037
+ const display = fileDisplayPath(file, idx).replace(/\\\\/g, "/");
2038
+ const parts = display.split("/").filter(Boolean);
2039
+ const filename = parts.pop() || display || `file-${idx + 1}`;
2040
+
2041
+ let node = root;
2042
+ let currentPath = "";
2043
+ for (const part of parts) {
2044
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
2045
+ if (!node.dirs.has(part)) {
2046
+ node.dirs.set(part, { name: part, path: currentPath, dirs: new Map(), files: [] });
2047
+ }
2048
+ node = node.dirs.get(part);
2049
+ }
2050
+ const coverage = expressionLineCoverageForTrace(Array.isArray(file.trace) ? file.trace : []);
2051
+ const coverageText = coverage.expected > 0 ? ` (${coverage.executed}/${coverage.expected})` : "";
2052
+ node.files.push({ label: filename, key, file, index: idx, path: display, coverageText });
2053
+ });
2054
+
2055
+ return root;
2056
+ }
2057
+
2058
+ function treeNodeContainsSelection(node, selectedKey) {
2059
+ if (!node) return false;
2060
+ if ((node.files || []).some((f) => f.key === selectedKey)) return true;
2061
+ for (const child of (node.dirs || new Map()).values()) {
2062
+ if (treeNodeContainsSelection(child, selectedKey)) return true;
2063
+ }
2064
+ return false;
2065
+ }
2066
+
2067
+ function renderFileTreeNode(node, selectedKey, onSelect, level) {
2068
+ const ul = document.createElement("ul");
2069
+ ul.className = "tree-list";
2070
+ ul.dataset.level = String(level || 0);
2071
+
2072
+ const dirs = Array.from((node.dirs || new Map()).values()).sort((a, b) => a.name.localeCompare(b.name));
2073
+ const files = Array.from(node.files || []).sort((a, b) => a.path.localeCompare(b.path));
2074
+
2075
+ dirs.forEach((dirNode) => {
2076
+ const li = document.createElement("li");
2077
+ li.className = "tree-dir";
2078
+
2079
+ const details = document.createElement("details");
2080
+ details.className = "tree-folder";
2081
+ details.open = true;
2082
+
2083
+ const summary = document.createElement("summary");
2084
+ summary.className = "tree-folder-label";
2085
+ summary.textContent = dirNode.name;
2086
+ details.appendChild(summary);
2087
+
2088
+ details.appendChild(renderFileTreeNode(dirNode, selectedKey, onSelect, (level || 0) + 1));
2089
+ li.appendChild(details);
2090
+ ul.appendChild(li);
2091
+ });
2092
+
2093
+ files.forEach((entry) => {
2094
+ const li = document.createElement("li");
2095
+ li.className = "tree-file";
2096
+
2097
+ const btn = document.createElement("button");
2098
+ btn.type = "button";
2099
+ btn.className = "tree-file-btn";
2100
+ const name = document.createElement("span");
2101
+ name.className = "tree-file-name";
2102
+ name.textContent = entry.label;
2103
+ btn.appendChild(name);
2104
+
2105
+ if (entry.coverageText) {
2106
+ const meta = document.createElement("span");
2107
+ meta.className = "tree-file-meta";
2108
+ meta.textContent = entry.coverageText;
2109
+ btn.appendChild(meta);
2110
+ }
2111
+
2112
+ btn.dataset.fileKey = entry.key;
2113
+ if (entry.key === selectedKey) {
2114
+ btn.classList.add("active");
2115
+ btn.setAttribute("aria-current", "page");
2116
+ }
2117
+ btn.addEventListener("click", () => onSelect(entry.key));
2118
+ li.appendChild(btn);
2119
+ ul.appendChild(li);
2120
+ });
2121
+
2122
+ return ul;
2123
+ }
2124
+
2125
+ function preferredInitialFileKey(files) {
2126
+ if (!files || files.length === 0) return null;
2127
+ const hashKey = parseHashStateFromLocation().fileKey;
2128
+ if (hashKey) {
2129
+ const byDisplay = files.find((file, idx) => fileUrlKey(file, idx) === hashKey);
2130
+ if (byDisplay) return fileUrlKey(byDisplay, files.indexOf(byDisplay));
2131
+
2132
+ const byPath = files.find((file) => String(file.path || "") === hashKey);
2133
+ if (byPath) return fileUrlKey(byPath, files.indexOf(byPath));
2134
+ }
2135
+ return fileUrlKey(files[0], 0);
2136
+ }
2137
+
2138
+ function preferredInitialLineNumber() {
2139
+ return parseHashStateFromLocation().line;
2140
+ }
2141
+
2142
+ function focusLineInViewer(viewer, lineno) {
2143
+ viewer.querySelectorAll(".line.line-target").forEach((el) => el.classList.remove("line-target"));
2144
+ if (!(lineno > 0)) return;
2145
+ const target = viewer.querySelector(`.line[data-line="${lineno}"]`);
2146
+ if (!target) return;
2147
+ target.classList.add("line-target");
2148
+ if (typeof target.scrollIntoView === "function") {
2149
+ target.scrollIntoView({ block: "center", inline: "nearest" });
2150
+ }
2151
+ }
2152
+
2153
+ function render(payload) {
2154
+ app.textContent = "";
2155
+
2156
+ const files = payload && Array.isArray(payload.files) ? payload.files : [];
2157
+ if (files.length === 0) {
2158
+ const empty = document.createElement("p");
2159
+ empty.className = "hint";
2160
+ empty.textContent = "No files to render.";
2161
+ app.appendChild(empty);
2162
+ return;
2163
+ }
2164
+
2165
+ const hint = document.createElement("div");
2166
+ hint.className = "hint";
2167
+ hint.textContent = "Hover highlighted text to see recorded values.";
2168
+ app.appendChild(hint);
2169
+
2170
+ const mode = document.createElement("div");
2171
+ mode.className = "mode";
2172
+ mode.textContent = payload && payload.meta && payload.meta.mode_text ? payload.meta.mode_text : "";
2173
+
2174
+ const commandText = payload && payload.meta && payload.meta.command ? String(payload.meta.command) : "";
2175
+ if (commandText) {
2176
+ const command = document.createElement("div");
2177
+ command.className = "command";
2178
+ command.textContent = `Command: ${commandText}`;
2179
+ app.appendChild(command);
2180
+ }
2181
+
2182
+ app.appendChild(mode);
2183
+
2184
+ const shell = document.createElement("div");
2185
+ shell.className = `report-layout${files.length <= 1 ? " single-file" : ""}`;
2186
+ app.appendChild(shell);
1347
2187
 
2188
+ const sidebar = document.createElement("aside");
2189
+ sidebar.className = "report-sidebar";
2190
+ shell.appendChild(sidebar);
2191
+
2192
+ const treeTitle = document.createElement("div");
2193
+ treeTitle.className = "tree-title";
2194
+ treeTitle.textContent = `Files (${files.length})`;
2195
+ sidebar.appendChild(treeTitle);
2196
+
2197
+ const treeMount = document.createElement("div");
2198
+ treeMount.className = "tree-scroll";
2199
+ sidebar.appendChild(treeMount);
2200
+
2201
+ const main = document.createElement("section");
2202
+ main.className = "report-main";
2203
+ shell.appendChild(main);
2204
+
2205
+ const mainHead = document.createElement("div");
2206
+ mainHead.className = "report-main-head";
2207
+ main.appendChild(mainHead);
2208
+
2209
+ const currentPath = document.createElement("div");
2210
+ currentPath.className = "current-file";
2211
+ mainHead.appendChild(currentPath);
2212
+
2213
+ const viewer = document.createElement("div");
2214
+ viewer.className = "report-viewer";
2215
+ main.appendChild(viewer);
2216
+
2217
+ const fileKeyToEntry = new Map();
2218
+ files.forEach((file, idx) => fileKeyToEntry.set(fileUrlKey(file, idx), { file, idx }));
2219
+
2220
+ let selectedKey = preferredInitialFileKey(files);
2221
+ let selectedLine = preferredInitialLineNumber();
2222
+
2223
+ function renderTree() {
2224
+ treeMount.textContent = "";
2225
+ const treeRoot = buildFileTree(files);
2226
+ treeMount.appendChild(renderFileTreeNode(treeRoot, selectedKey, (key) => selectFile(key, true, null), 0));
2227
+ }
2228
+
2229
+ function renderSelectedFile() {
2230
+ viewer.textContent = "";
2231
+ const entry = fileKeyToEntry.get(selectedKey);
2232
+ if (!entry) return;
2233
+
2234
+ currentPath.textContent = fileDisplayPath(entry.file, entry.idx);
2235
+
2236
+ viewer.appendChild(renderFileSection(entry.file, entry.idx, {
2237
+ includeTitle: false,
2238
+ lineHref: (lineno) => buildHashForSelection(selectedKey, lineno),
2239
+ onLineClick: (lineno) => selectLine(lineno, true)
2240
+ }));
2241
+ bindMarkerHover(viewer);
2242
+ focusLineInViewer(viewer, selectedLine);
2243
+ }
2244
+
2245
+ function selectLine(lineno, updateHash) {
2246
+ const n = Number(lineno);
2247
+ selectedLine = Number.isFinite(n) && n > 0 ? n : null;
2248
+ renderSelectedFile();
2249
+ if (updateHash) setLocationHashSelection(selectedKey, selectedLine);
2250
+ }
2251
+
2252
+ function selectFile(key, updateHash, nextLine) {
2253
+ if (!fileKeyToEntry.has(key)) return;
2254
+ if (arguments.length >= 3) {
2255
+ selectedLine = nextLine && Number(nextLine) > 0 ? Number(nextLine) : null;
2256
+ } else if (key !== selectedKey) {
2257
+ selectedLine = null;
2258
+ }
2259
+ selectedKey = key;
2260
+ renderTree();
2261
+ renderSelectedFile();
2262
+ if (updateHash) setLocationHashSelection(key, selectedLine);
2263
+ }
2264
+
2265
+ window.addEventListener("hashchange", () => {
2266
+ const state = parseHashStateFromLocation();
2267
+ if (state.fileKey && state.fileKey !== selectedKey && fileKeyToEntry.has(state.fileKey)) {
2268
+ selectFile(state.fileKey, false, state.line);
2269
+ return;
2270
+ }
2271
+ if (state.fileKey === selectedKey || (!state.fileKey && selectedKey)) {
2272
+ selectedLine = state.line;
2273
+ renderSelectedFile();
2274
+ }
2275
+ });
2276
+
2277
+ selectFile(selectedKey, true);
2278
+ }
2279
+
2280
+ try {
2281
+ const payload = JSON.parse(payloadEl.textContent || "{}");
2282
+ render(payload);
2283
+ } catch (error) {
2284
+ app.textContent = `Failed to render Lumitrace HTML: ${error}`;
2285
+ }
2286
+ })();'
2287
+ end
2288
+
2289
+ def self.html_report_styles
2290
+ <<~CSS
2291
+ body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
2292
+ .report-layout { display: grid; grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); gap: 16px; align-items: start; }
2293
+ .report-layout.single-file { grid-template-columns: minmax(0, 1fr); }
2294
+ .report-sidebar { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 12px; position: sticky; top: 16px; max-height: calc(100vh - 48px); overflow: hidden; }
2295
+ .report-layout.single-file .report-sidebar { display: none; }
2296
+ .tree-title { color: #444; font-size: 12px; margin-bottom: 8px; }
2297
+ .tree-scroll { overflow: auto; max-height: calc(100vh - 96px); }
2298
+ .tree-list { list-style: none; margin: 0; padding: 0; }
2299
+ .tree-list[data-level]:not([data-level="0"]) { margin-left: 14px; border-left: 1px dashed #e5dfd0; padding-left: 8px; }
2300
+ .tree-dir, .tree-file { margin: 2px 0; }
2301
+ .tree-folder { }
2302
+ .tree-folder-label { cursor: pointer; color: #4d473f; user-select: none; }
2303
+ .tree-folder-label::marker { color: #999; }
2304
+ .tree-file-btn { width: 100%; text-align: left; border: 0; background: transparent; color: #2a2a2a; padding: 4px 6px; border-radius: 6px; cursor: pointer; font: inherit; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
2305
+ .tree-file-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2306
+ .tree-file-meta { color: #6f6a62; font-size: 12px; white-space: nowrap; }
2307
+ .tree-file-btn:hover { background: #fff2c6; }
2308
+ .tree-file-btn.active { background: #f0ffe7; color: #1b5e3d; }
2309
+ .tree-file-btn.active .tree-file-meta { color: #1b5e3d; }
2310
+ .report-main { min-width: 0; }
2311
+ .report-main-head { display: flex; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 8px; }
2312
+ .current-file { color: #333; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2313
+ .report-viewer { min-width: 0; }
2314
+ .file-section { min-width: 0; }
2315
+ .code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
2316
+ .line { display: block; box-sizing: border-box; padding: 2px 8px; }
2317
+ .line:hover { background: #fff2c6; }
2318
+ .line.hit { background: #f0ffe7; }
2319
+ .line.miss { background: #ffecec; }
2320
+ .line.line-target { box-shadow: inset 3px 0 #2f6f8e; background: #e9f4fb; }
2321
+ .line.ellipsis { color: #999; }
2322
+ .ln { display: inline-block; width: 3em; color: #888; user-select: none; }
2323
+ .ln-link { color: inherit; text-decoration: none; }
2324
+ .ln-link:hover { text-decoration: underline; color: #2f6f8e; }
2325
+ .hint { color: #666; margin-bottom: 4px; }
2326
+ .command { color: #555; margin-bottom: 4px; font-size: 12px; overflow-wrap: anywhere; }
2327
+ .mode { color: #444; margin-bottom: 8px; }
2328
+ .report-footer { margin-top: 16px; color: #666; font-size: 12px; }
2329
+ .report-footer a { color: #2f6f8e; text-decoration: none; }
2330
+ .report-footer a:hover { text-decoration: underline; }
2331
+ .file { margin: 24px 0 8px; font-size: 16px; color: #333; }
2332
+ .expr { position: relative; display: inline-block; padding-bottom: 1px; }
2333
+ .expr.hit { }
2334
+ .expr.depth-1 { --hl: #7fbf7f; }
2335
+ .expr.depth-2 { --hl: #6fa8ff; }
2336
+ .expr.depth-3 { --hl: #ffb347; }
2337
+ .expr.depth-4 { --hl: #d78bff; }
2338
+ .expr.depth-5 { --hl: #ff6f91; }
2339
+ .expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
2340
+ .expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
2341
+ .marker { position: relative; display: inline-block; margin-left: 4px; cursor: help; font-size: 10px; line-height: 1; user-select: none; -webkit-user-select: none; -moz-user-select: none; }
2342
+ .marker.miss { color: #c07070; }
2343
+ .marker.arg { color: #2f6f8e; }
2344
+ .marker .tooltip {
2345
+ display: none;
2346
+ position: absolute;
2347
+ left: 0;
2348
+ bottom: 100%;
2349
+ margin-bottom: 6px;
2350
+ background: #2b2b2b;
2351
+ color: #fff;
2352
+ padding: 4px 6px;
2353
+ border-radius: 4px;
2354
+ font-size: 12px;
2355
+ white-space: pre;
2356
+ min-width: 16ch;
2357
+ max-width: 90vw;
2358
+ overflow-x: auto;
2359
+ overflow-y: hidden;
2360
+ z-index: 10;
2361
+ pointer-events: auto;
2362
+ }
2363
+ .marker:hover .tooltip,
2364
+ .marker:focus-within .tooltip,
2365
+ .marker .tooltip:hover { display: block; }
2366
+ .noscript { color: #666; }
2367
+ @media (max-width: 900px) {
2368
+ body { padding: 16px; }
2369
+ .report-layout { grid-template-columns: 1fr; }
2370
+ .report-sidebar { position: static; max-height: none; }
2371
+ .tree-scroll { max-height: 220px; }
2372
+ .report-main-head { flex-direction: column; align-items: flex-start; }
2373
+ }
2374
+ CSS
2375
+ end
2376
+
2377
+ def self.footer_version_suffix
2378
+ return "" unless defined?(Lumitrace::VERSION) && Lumitrace::VERSION
2379
+ " v#{Lumitrace::VERSION}"
2380
+ end
2381
+
2382
+ def self.render_payload_html(payload)
1348
2383
  <<~HTML
1349
2384
  <!doctype html>
1350
2385
  <html>
@@ -1352,71 +2387,22 @@ module GenerateResultedHtml
1352
2387
  <meta charset="utf-8">
1353
2388
  <title>Recorded Result View</title>
1354
2389
  <style>
1355
- body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
1356
- .code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
1357
- .line { display: inline-block; width: 100%; box-sizing: border-box; padding: 2px 8px; }
1358
- .line:hover { background: #fff2c6; }
1359
- .line.hit { background: #f0ffe7; }
1360
- .line.miss { background: #ffecec; }
1361
- .line.ellipsis { color: #999; }
1362
- .ln { display: inline-block; width: 3em; color: #888; user-select: none; }
1363
- .hint { color: #666; margin-bottom: 4px; }
1364
- .mode { color: #444; margin-bottom: 8px; }
1365
- .expr { position: relative; display: inline-block; padding-bottom: 1px; }
1366
- .expr.hit { }
1367
- .expr.depth-1 { --hl: #7fbf7f; }
1368
- .expr.depth-2 { --hl: #6fa8ff; }
1369
- .expr.depth-3 { --hl: #ffb347; }
1370
- .expr.depth-4 { --hl: #d78bff; }
1371
- .expr.depth-5 { --hl: #ff6f91; }
1372
- .expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
1373
- .expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
1374
- .marker { position: relative; display: inline-block; margin-left: 4px; cursor: help; font-size: 10px; line-height: 1; user-select: none; -webkit-user-select: none; -moz-user-select: none; }
1375
- .marker.miss { color: #c07070; }
1376
- .marker.arg { color: #2f6f8e; }
1377
- .marker .tooltip {
1378
- display: none;
1379
- position: absolute;
1380
- left: 0;
1381
- top: 100%;
1382
- margin-top: 4px;
1383
- background: #2b2b2b;
1384
- color: #fff;
1385
- padding: 4px 6px;
1386
- border-radius: 4px;
1387
- font-size: 12px;
1388
- white-space: pre;
1389
- min-width: 16ch;
1390
- max-width: 90vw;
1391
- overflow-x: auto;
1392
- overflow-y: hidden;
1393
- z-index: 10;
1394
- pointer-events: auto;
1395
- }
1396
- .marker:hover .tooltip,
1397
- .marker:focus-within .tooltip,
1398
- .marker .tooltip:hover { display: block; }
2390
+ #{html_report_styles}
1399
2391
  </style>
1400
2392
  </head>
1401
2393
  <body>
1402
- <div class="hint">Hover highlighted text to see recorded values.</div>
1403
- <div class="mode">#{esc(mode_info[:text])}</div>
1404
- <pre class="code"><code>
1405
- #{html_lines.join("")}
1406
- </code></pre>
2394
+ <div id="lumitrace-app"></div>
2395
+ <div class="report-footer">Generated by <a href="https://ko1.github.io/lumitrace/" target="_blank" rel="noopener noreferrer">lumitrace</a>#{footer_version_suffix}.</div>
2396
+ <noscript><p class="noscript">Lumitrace HTML report requires JavaScript to render the source and trace view.</p></noscript>
2397
+ <script id="lumitrace-payload" type="application/json">#{payload_json_for_script(payload)}#{'</scr' + 'ipt>'}
2398
+ <script>
2399
+ #{html_renderer_js}
2400
+ #{'</scr' + 'ipt>'}
1407
2401
  </body>
1408
2402
  </html>
1409
2403
  HTML
1410
2404
  end
1411
2405
 
1412
- def self.esc(s)
1413
- s.to_s
1414
- .gsub("&", "&amp;")
1415
- .gsub("<", "&lt;")
1416
- .gsub(">", "&gt;")
1417
- .gsub('"', "&quot;")
1418
- end
1419
-
1420
2406
  def self.detect_collect_mode(events)
1421
2407
  arr = Array(events)
1422
2408
  return "history" if arr.any? { |e| e.key?(:sampled_values) || e.key?("sampled_values") }
@@ -1566,6 +2552,7 @@ module GenerateResultedHtml
1566
2552
  def self.comment_value_with_total_for_line(events)
1567
2553
  best = best_event_for_line(events)
1568
2554
  return nil unless best
2555
+ return nil if best[:total].to_i <= 0
1569
2556
 
1570
2557
  sampled_last = best[:sampled_values]&.last
1571
2558
  v, t = last_value_to_pair(sampled_last)
@@ -1669,9 +2656,16 @@ module GenerateResultedHtml
1669
2656
 
1670
2657
  next if t <= s
1671
2658
  spans << { start_col: s, end_col: t }
1672
- key_id = e[:key].join(":")
1673
- buckets[e[:key]] = {
1674
- key: e[:key],
2659
+ event_key = e[:key] || [
2660
+ e[:file],
2661
+ e[:start_line].to_i,
2662
+ e[:start_col].to_i,
2663
+ e[:end_line].to_i,
2664
+ e[:end_col].to_i
2665
+ ]
2666
+ key_id = event_key.join(":")
2667
+ buckets[event_key] = {
2668
+ key: event_key,
1675
2669
  key_id: key_id,
1676
2670
  start_col: s,
1677
2671
  end_col: t,
@@ -1799,34 +2793,6 @@ module GenerateResultedHtml
1799
2793
  ranges.any? { |(s, e)| line >= s && line <= e }
1800
2794
  end
1801
2795
 
1802
- def self.add_missing_events(events, source, filename, ranges)
1803
- expected = RecordInstrument.collect_locations_from_source(source, ranges || [])
1804
- existing = {}
1805
- events.each do |e|
1806
- key = [e[:file], e[:start_line], e[:start_col], e[:end_line], e[:end_col]]
1807
- existing[key] = true
1808
- end
1809
- expected.each do |loc|
1810
- key = [filename, loc[:start_line], loc[:start_col], loc[:end_line], loc[:end_col]]
1811
- next if existing[key]
1812
- events << {
1813
- key: key,
1814
- file: key[0],
1815
- start_line: key[1],
1816
- start_col: key[2],
1817
- end_line: key[3],
1818
- end_col: key[4],
1819
- kind: loc[:kind],
1820
- name: loc[:name],
1821
- sampled_values: [],
1822
- types: {},
1823
- total: 0
1824
- }
1825
- existing[key] = true
1826
- end
1827
- events
1828
- end
1829
-
1830
2796
  def self.line_stats(source, ranges, events, filename)
1831
2797
  expected_by_line = Hash.new(0)
1832
2798
  RecordInstrument.collect_locations_from_source(source, ranges || []).each do |loc|
@@ -1850,293 +2816,131 @@ module GenerateResultedHtml
1850
2816
  [expected_by_line, executed_by_line]
1851
2817
  end
1852
2818
 
1853
- def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil)
1854
- raw_events = JSON.parse(File.read(events_path))
1855
- render_all_from_events(raw_events, root: root, ranges_by_file: ranges_by_file, collect_mode: collect_mode, max_samples: max_samples)
1856
- end
1857
-
1858
- def self.render_all_from_events(events, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil)
1859
- mode_info = resolve_mode_info(events, collect_mode: collect_mode, max_samples: max_samples)
1860
- events = normalize_events(events)
1861
- by_file = events.group_by { |e| e[:file] }
1862
- ranges_by_file = normalize_ranges_by_file(ranges_by_file)
1863
-
1864
- target_paths = by_file.keys
1865
-
1866
- sections = target_paths.sort.map do |path|
2819
+ def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil, logger: nil, command_text: nil)
2820
+ raw = JSON.parse(File.read(events_path))
2821
+ raw_events = raw.is_a?(Hash) && raw.key?("events") ? raw["events"] : raw
2822
+ render_all_from_events(
2823
+ raw_events,
2824
+ root: root,
2825
+ ranges_by_file: ranges_by_file,
2826
+ collect_mode: collect_mode,
2827
+ max_samples: max_samples,
2828
+ logger: logger,
2829
+ command_text: command_text
2830
+ )
2831
+ end
2832
+
2833
+ def self.render_all_from_events(events, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil, logger: nil, command_text: nil)
2834
+ normalized = time_step("normalize_events", logger) { normalize_events(events) }
2835
+ render_all_from_normalized_events(
2836
+ normalized,
2837
+ root: root,
2838
+ ranges_by_file: ranges_by_file,
2839
+ collect_mode: collect_mode,
2840
+ max_samples: max_samples,
2841
+ logger: logger,
2842
+ command_text: command_text
2843
+ )
2844
+ end
2845
+
2846
+ def self.render_all_from_normalized_events(events, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil, logger: nil, command_text: nil)
2847
+ total_start = monotonic_now
2848
+
2849
+ mode_info = time_step("resolve_mode", logger) do
2850
+ resolve_mode_info(events, collect_mode: collect_mode, max_samples: max_samples)
2851
+ end
2852
+ by_file = time_step("group_by_file", logger) { events.group_by { |e| e[:file] } }
2853
+ ranges_by_file = time_step("normalize_ranges_by_file", logger) { normalize_ranges_by_file(ranges_by_file) }
2854
+
2855
+ files = []
2856
+ by_file.keys.sort.each do |path|
1867
2857
  next unless File.exist?(path)
2858
+ file_start = monotonic_now
2859
+ read_start = logger ? monotonic_now : nil
1868
2860
  src = File.read(path)
2861
+ read_ms = read_start ? (monotonic_now - read_start) * 1000.0 : nil
1869
2862
  if ranges_by_file
1870
2863
  next unless ranges_by_file.key?(path)
1871
2864
  ranges = ranges_by_file[path] || []
1872
2865
  else
1873
2866
  ranges = nil
1874
2867
  end
1875
- file_events = add_missing_events((by_file[path] || []).dup, src, path, ranges)
1876
- expected_by_line, executed_by_line = line_stats(src, ranges, file_events, path)
1877
- html_lines = []
1878
- prev_lineno = nil
1879
- first_lineno = nil
1880
- last_lineno = nil
1881
- src.lines.each_with_index do |line, idx|
1882
- lineno = idx + 1
1883
- next if ranges && !line_in_ranges?(lineno, ranges)
1884
- first_lineno ||= lineno
1885
- if prev_lineno && lineno > prev_lineno + 1
1886
- html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
1887
- end
1888
- line_text = line.chomp
1889
- evs = aggregate_events_for_line(file_events, lineno, line_text.length)
1890
- expected = expected_by_line[lineno]
1891
- executed = executed_by_line[lineno]
1892
- line_class = line_class_for(expected, executed)
1893
- if expected > 0 && executed == 0
1894
- evs.each { |e| e[:suppress_miss] = true }
1895
- end
1896
- if evs.empty?
1897
- html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{esc(line_text)}</span>\n"
1898
- else
1899
- rendered = render_line_with_events(line_text, evs)
1900
- html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{rendered}</span>\n"
1901
- end
1902
- prev_lineno = lineno
1903
- last_lineno = lineno
1904
- end
1905
- if first_lineno && first_lineno > 1
1906
- html_lines.unshift("<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n")
1907
- end
1908
- if last_lineno && last_lineno < src.lines.length
1909
- html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
2868
+ rel = path.start_with?(root) ? path.sub(root + File::SEPARATOR, "") : path
2869
+ select_start = logger ? monotonic_now : nil
2870
+ file_events = by_file[path] || []
2871
+ select_ms = select_start ? (monotonic_now - select_start) * 1000.0 : nil
2872
+ payload_file_start = logger ? monotonic_now : nil
2873
+ files << build_html_payload_file(
2874
+ path: path,
2875
+ display_path: rel,
2876
+ source: src,
2877
+ ranges: ranges,
2878
+ trace_events: file_events,
2879
+ logger: logger
2880
+ )
2881
+ payload_file_ms = payload_file_start ? (monotonic_now - payload_file_start) * 1000.0 : nil
2882
+ if logger
2883
+ elapsed_ms = (monotonic_now - file_start) * 1000.0
2884
+ logger.call(format("html render: file %s read=%.1fms select=%.1fms payload=%.1fms events=%d bytes=%d total=%.1fms", rel, read_ms, select_ms, payload_file_ms, file_events.length, src.bytesize, elapsed_ms))
1910
2885
  end
2886
+ end
1911
2887
 
1912
- rel = path.start_with?(root) ? path.sub(root + File::SEPARATOR, "") : path
1913
- <<~HTML
1914
- <h2 class="file">#{esc(rel)}</h2>
1915
- <pre class="code"><code>
1916
- #{html_lines.join("")}
1917
- </code></pre>
1918
- HTML
1919
- end.compact.join("\n")
2888
+ payload = time_step("build_payload", logger) { build_html_payload(mode_info: mode_info, files: files, command_text: command_text) }
2889
+ html = time_step("render_payload_html", logger) { render_payload_html(payload) }
2890
+ if logger
2891
+ total_ms = (monotonic_now - total_start) * 1000.0
2892
+ logger.call(format("html render: total files=%d events=%d html_bytes=%d %.1fms", files.length, events.length, html.bytesize, total_ms))
2893
+ end
2894
+ html
2895
+ end
1920
2896
 
1921
- <<~HTML
1922
- <!doctype html>
1923
- <html>
1924
- <head>
1925
- <meta charset="utf-8">
1926
- <title>Recorded Result View</title>
1927
- <style>
1928
- body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
1929
- .code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
1930
- .line { display: inline-block; width: 100%; box-sizing: border-box; padding: 2px 8px; }
1931
- .line:hover { background: #fff2c6; }
1932
- .line.hit { background: #f0ffe7; }
1933
- .line.miss { background: #ffecec; }
1934
- .line.ellipsis { color: #999; }
1935
- .ln { display: inline-block; width: 3em; color: #888; user-select: none; }
1936
- .hint { color: #666; margin-bottom: 4px; }
1937
- .mode { color: #444; margin-bottom: 8px; }
1938
- .file { margin: 24px 0 8px; font-size: 16px; color: #333; }
1939
- .expr { position: relative; display: inline-block; padding-bottom: 1px; }
1940
- .expr.hit { }
1941
- .expr.depth-1 { --hl: #7fbf7f; }
1942
- .expr.depth-2 { --hl: #6fa8ff; }
1943
- .expr.depth-3 { --hl: #ffb347; }
1944
- .expr.depth-4 { --hl: #d78bff; }
1945
- .expr.depth-5 { --hl: #ff6f91; }
1946
- .expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
1947
- .expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
1948
- .marker { position: relative; display: inline-block; margin-left: 4px; cursor: help; font-size: 10px; line-height: 1; user-select: none; -webkit-user-select: none; -moz-user-select: none; }
1949
- .marker.miss { color: #c07070; }
1950
- .marker.arg { color: #2f6f8e; }
1951
- .marker .tooltip {
1952
- display: none;
1953
- position: absolute;
1954
- left: 0;
1955
- top: 100%;
1956
- margin-top: 4px;
1957
- background: #2b2b2b;
1958
- color: #fff;
1959
- padding: 4px 6px;
1960
- border-radius: 4px;
1961
- font-size: 12px;
1962
- white-space: pre;
1963
- min-width: 16ch;
1964
- max-width: 90vw;
1965
- overflow-x: auto;
1966
- overflow-y: hidden;
1967
- z-index: 10;
1968
- pointer-events: auto;
1969
- }
1970
- .marker:hover .tooltip,
1971
- .marker:focus-within .tooltip,
1972
- .marker .tooltip:hover { display: block; }
1973
- </style>
1974
- </head>
1975
- <body>
1976
- <div class="hint">Hover highlighted text to see recorded values.</div>
1977
- <div class="mode">#{esc(mode_info[:text])}</div>
1978
- #{sections}
1979
- <script>
1980
- (function() {
1981
- document.querySelectorAll('.marker').forEach(marker => {
1982
- marker.addEventListener('mouseenter', () => {
1983
- document.querySelectorAll('.expr').forEach(e => e.classList.remove('active'));
1984
- const key = marker.dataset.key;
1985
- if (key) {
1986
- document.querySelectorAll(`.expr[data-key="${key}"]`).forEach(e => e.classList.add('active'));
1987
- } else {
1988
- marker.closest('.expr')?.classList.add('active');
1989
- }
1990
- });
1991
- marker.addEventListener('mouseleave', () => {
1992
- const key = marker.dataset.key;
1993
- if (key) {
1994
- document.querySelectorAll(`.expr[data-key="${key}"]`).forEach(e => e.classList.remove('active'));
1995
- } else {
1996
- marker.closest('.expr')?.classList.remove('active');
1997
- }
1998
- });
1999
- });
2000
- })();
2001
- #{'</scr' + 'ipt>'}
2002
- </body>
2003
- </html>
2004
- HTML
2897
+ def self.render_source_from_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil, command_text: nil)
2898
+ render_source_from_normalized_events(
2899
+ source,
2900
+ normalize_events(events),
2901
+ filename: filename,
2902
+ ranges: ranges,
2903
+ collect_mode: collect_mode,
2904
+ max_samples: max_samples,
2905
+ command_text: command_text
2906
+ )
2005
2907
  end
2006
2908
 
2007
- def self.render_source_from_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil)
2909
+ def self.render_source_from_normalized_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil, command_text: nil)
2008
2910
  mode_info = resolve_mode_info(events, collect_mode: collect_mode, max_samples: max_samples)
2009
- events = normalize_events(events)
2010
2911
  ranges = normalize_ranges(ranges)
2011
- target_events = add_missing_events(events.select { |e| e[:file] == filename }, source, filename, ranges)
2012
- expected_by_line, executed_by_line = line_stats(source, ranges, target_events, filename)
2912
+ target_events = events.select { |e| e[:file] == filename }
2013
2913
 
2014
- html_lines = []
2015
- prev_lineno = nil
2016
- first_lineno = nil
2017
- last_lineno = nil
2018
- source.lines.each_with_index do |line, idx|
2019
- lineno = idx + 1
2020
- next if ranges && !line_in_ranges?(lineno, ranges)
2021
- first_lineno ||= lineno
2022
- if prev_lineno && lineno > prev_lineno + 1
2023
- html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
2024
- end
2025
- line_text = line.chomp
2026
- evs = aggregate_events_for_line(target_events, lineno, line_text.length)
2027
- expected = expected_by_line[lineno]
2028
- executed = executed_by_line[lineno]
2029
- line_class = line_class_for(expected, executed)
2030
- if expected > 0 && executed == 0
2031
- evs.each { |e| e[:suppress_miss] = true }
2032
- end
2033
- if evs.empty?
2034
- html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{esc(line_text)}</span>\n"
2035
- else
2036
- rendered = render_line_with_events(line_text, evs)
2037
- html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{rendered}</span>\n"
2038
- end
2039
- prev_lineno = lineno
2040
- last_lineno = lineno
2041
- end
2042
- if first_lineno && first_lineno > 1
2043
- html_lines.unshift("<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n")
2044
- end
2045
- if last_lineno && last_lineno < source.lines.length
2046
- html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
2047
- end
2914
+ payload = build_html_payload(
2915
+ mode_info: mode_info,
2916
+ command_text: command_text,
2917
+ files: [
2918
+ build_html_payload_file(
2919
+ path: filename,
2920
+ display_path: filename,
2921
+ source: source,
2922
+ ranges: ranges,
2923
+ trace_events: target_events
2924
+ )
2925
+ ]
2926
+ )
2048
2927
 
2049
- <<~HTML
2050
- <!doctype html>
2051
- <html>
2052
- <head>
2053
- <meta charset="utf-8">
2054
- <title>Recorded Result View</title>
2055
- <style>
2056
- body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
2057
- .code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
2058
- .line { display: inline-block; width: 100%; box-sizing: border-box; padding: 2px 8px; }
2059
- .line:hover { background: #fff2c6; }
2060
- .line.hit { background: #f0ffe7; }
2061
- .line.miss { background: #ffecec; }
2062
- .line.ellipsis { color: #999; }
2063
- .ln { display: inline-block; width: 3em; color: #888; user-select: none; }
2064
- .hint { color: #666; margin-bottom: 4px; }
2065
- .mode { color: #444; margin-bottom: 8px; }
2066
- .file { margin: 24px 0 8px; font-size: 16px; color: #333; }
2067
- .expr { position: relative; display: inline-block; padding-bottom: 1px; }
2068
- .expr.hit { }
2069
- .expr.depth-1 { --hl: #7fbf7f; }
2070
- .expr.depth-2 { --hl: #6fa8ff; }
2071
- .expr.depth-3 { --hl: #ffb347; }
2072
- .expr.depth-4 { --hl: #d78bff; }
2073
- .expr.depth-5 { --hl: #ff6f91; }
2074
- .expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
2075
- .expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
2076
- .marker { position: relative; display: inline-block; margin-left: 4px; cursor: help; font-size: 10px; line-height: 1; user-select: none; -webkit-user-select: none; -moz-user-select: none; }
2077
- .marker.miss { color: #c07070; }
2078
- .marker.arg { color: #2f6f8e; }
2079
- .marker .tooltip {
2080
- display: none;
2081
- position: absolute;
2082
- left: 0;
2083
- top: 100%;
2084
- margin-top: 4px;
2085
- background: #2b2b2b;
2086
- color: #fff;
2087
- padding: 4px 6px;
2088
- border-radius: 4px;
2089
- font-size: 12px;
2090
- white-space: pre;
2091
- min-width: 16ch;
2092
- max-width: 90vw;
2093
- overflow-x: auto;
2094
- overflow-y: hidden;
2095
- z-index: 10;
2096
- pointer-events: auto;
2097
- }
2098
- .marker:hover .tooltip,
2099
- .marker:focus-within .tooltip,
2100
- .marker .tooltip:hover { display: block; }
2101
- </style>
2102
- </head>
2103
- <body>
2104
- <div class="hint">Hover highlighted text to see recorded values.</div>
2105
- <div class="mode">#{esc(mode_info[:text])}</div>
2106
- <h2 class="file">#{esc(filename)}</h2>
2107
- <pre class="code"><code>
2108
- #{html_lines.join("")}
2109
- </code></pre>
2110
- <script>
2111
- (function() {
2112
- document.querySelectorAll('.marker').forEach(marker => {
2113
- marker.addEventListener('mouseenter', () => {
2114
- document.querySelectorAll('.expr').forEach(e => e.classList.remove('active'));
2115
- const key = marker.dataset.key;
2116
- if (key) {
2117
- document.querySelectorAll(`.expr[data-key="${key}"]`).forEach(e => e.classList.add('active'));
2118
- } else {
2119
- marker.closest('.expr')?.classList.add('active');
2120
- }
2121
- });
2122
- marker.addEventListener('mouseleave', () => {
2123
- const key = marker.dataset.key;
2124
- if (key) {
2125
- document.querySelectorAll(`.expr[data-key="${key}"]`).forEach(e => e.classList.remove('active'));
2126
- } else {
2127
- marker.closest('.expr')?.classList.remove('active');
2128
- }
2129
- });
2130
- });
2131
- })();
2132
- #{'</scr' + 'ipt>'}
2133
- </body>
2134
- </html>
2135
- HTML
2928
+ render_payload_html(payload)
2136
2929
  end
2137
2930
 
2138
2931
  def self.render_text_from_events(source, events, filename: "script.rb", ranges: nil, with_header: true, header_label: nil, tty: nil)
2139
- events = normalize_events(events)
2932
+ render_text_from_normalized_events(
2933
+ source,
2934
+ normalize_events(events),
2935
+ filename: filename,
2936
+ ranges: ranges,
2937
+ with_header: with_header,
2938
+ header_label: header_label,
2939
+ tty: tty
2940
+ )
2941
+ end
2942
+
2943
+ def self.render_text_from_normalized_events(source, events, filename: "script.rb", ranges: nil, with_header: true, header_label: nil, tty: nil)
2140
2944
  ranges = normalize_ranges(ranges)
2141
2945
  target_events = events.select { |e| e[:file] == filename }
2142
2946
  term_width = tty ? terminal_width : nil
@@ -2158,7 +2962,7 @@ module GenerateResultedHtml
2158
2962
  ]
2159
2963
  next if seen[key]
2160
2964
  seen[key] = true
2161
- executed_by_line[line] += 1 if line
2965
+ executed_by_line[line] += 1 if line && (e[:total] || e["total"]).to_i > 0
2162
2966
  end
2163
2967
 
2164
2968
  out = +""
@@ -2259,7 +3063,10 @@ module GenerateResultedHtml
2259
3063
  end
2260
3064
 
2261
3065
  def self.render_text_all_from_events(events, root: Dir.pwd, ranges_by_file: nil, tty: nil)
2262
- events = normalize_events(events)
3066
+ render_text_all_from_normalized_events(normalize_events(events), root: root, ranges_by_file: ranges_by_file, tty: tty)
3067
+ end
3068
+
3069
+ def self.render_text_all_from_normalized_events(events, root: Dir.pwd, ranges_by_file: nil, tty: nil)
2263
3070
  by_file = events.group_by { |e| e[:file] }
2264
3071
  ranges_by_file = normalize_ranges_by_file(ranges_by_file)
2265
3072
 
@@ -2273,7 +3080,7 @@ module GenerateResultedHtml
2273
3080
  ranges = nil
2274
3081
  end
2275
3082
  rel = path.start_with?(root) ? path.sub(root + File::SEPARATOR, "") : path
2276
- render_text_from_events(src, events, filename: path, ranges: ranges, with_header: true, header_label: rel, tty: tty)
3083
+ render_text_from_normalized_events(src, events, filename: path, ranges: ranges, with_header: true, header_label: rel, tty: tty)
2277
3084
  end.compact
2278
3085
 
2279
3086
  header = "\n=== Lumitrace Results (text) ===\n\n"