lumitrace 0.4.2 → 0.5.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.5.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.5.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.5.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
@@ -1292,6 +1349,20 @@ require "json"
1292
1349
 
1293
1350
  module Lumitrace
1294
1351
  module GenerateResultedHtml
1352
+ RENDERER_JS_PATH = File.expand_path("generate_resulted_html_renderer.js", __dir__)
1353
+
1354
+ def self.monotonic_now
1355
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1356
+ end
1357
+
1358
+ def self.time_step(label, logger = nil)
1359
+ start = monotonic_now
1360
+ result = yield
1361
+ elapsed_ms = (monotonic_now - start) * 1000.0
1362
+ logger&.call(format("html render: %s %.1fms", label, elapsed_ms))
1363
+ result
1364
+ end
1365
+
1295
1366
  def self.render(source_path, events_path, ranges: nil, collect_mode: nil, max_samples: nil)
1296
1367
  unless File.exist?(events_path)
1297
1368
  abort "missing #{events_path}"
@@ -1301,50 +1372,970 @@ module GenerateResultedHtml
1301
1372
  end
1302
1373
 
1303
1374
  raw_events = JSON.parse(File.read(events_path))
1375
+ src = File.read(source_path)
1304
1376
  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)
1377
+ normalized_ranges = normalize_ranges(ranges)
1378
+ events = normalize_events(raw_events).select { |e| e[:file] == source_path }
1379
+
1380
+ payload = build_html_payload(
1381
+ mode_info: mode_info,
1382
+ files: [
1383
+ build_html_payload_file(
1384
+ path: source_path,
1385
+ display_path: File.basename(source_path),
1386
+ source: src,
1387
+ ranges: normalized_ranges,
1388
+ trace_events: events
1389
+ )
1390
+ ]
1391
+ )
1307
1392
 
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)
1393
+ render_payload_html(payload)
1394
+ end
1312
1395
 
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
1396
+ def self.esc(s)
1397
+ s.to_s
1398
+ .gsub("&", "&amp;")
1399
+ .gsub("<", "&lt;")
1400
+ .gsub(">", "&gt;")
1401
+ .gsub('"', "&quot;")
1402
+ end
1403
+
1404
+ def self.build_html_payload(mode_info:, files:, command_text: nil)
1405
+ meta = {
1406
+ mode: mode_info[:mode],
1407
+ mode_text: mode_info[:text],
1408
+ max_samples: mode_info[:max_samples]
1409
+ }
1410
+ meta[:command] = command_text if command_text && !command_text.to_s.empty?
1411
+ {
1412
+ version: 1,
1413
+ meta: meta,
1414
+ files: files
1415
+ }
1416
+ end
1417
+
1418
+ def self.build_html_payload_file(path:, display_path:, source:, ranges:, trace_events:, logger: nil)
1419
+ sort_start = logger ? monotonic_now : nil
1420
+ sorted_events = Array(trace_events).sort_by do |e|
1421
+ [e[:start_line].to_i, e[:start_col].to_i, e[:end_line].to_i, e[:end_col].to_i]
1340
1422
  end
1341
- if first_lineno && first_lineno > 1
1342
- html_lines.unshift("<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n")
1423
+ sort_ms = sort_start ? (monotonic_now - sort_start) * 1000.0 : nil
1424
+
1425
+ map_start = logger ? monotonic_now : nil
1426
+ trace_payload = sorted_events.map { |e| event_to_html_trace_payload(e) }
1427
+ map_ms = map_start ? (monotonic_now - map_start) * 1000.0 : nil
1428
+ if logger
1429
+ logger.call(format("html render: payload_file %s sort=%.1fms map=%.1fms events=%d", display_path, sort_ms, map_ms, sorted_events.length))
1343
1430
  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"
1431
+
1432
+ {
1433
+ path: path,
1434
+ display_path: display_path,
1435
+ source: source,
1436
+ ranges: ranges,
1437
+ trace: trace_payload
1438
+ }
1439
+ end
1440
+
1441
+ def self.event_to_html_trace_payload(e)
1442
+ sampled_values = e[:sampled_values]
1443
+ if (sampled_values.nil? || sampled_values.empty?) && e[:last_value]
1444
+ sampled_values = [e[:last_value]]
1346
1445
  end
1446
+ {
1447
+ location: [
1448
+ e[:start_line].to_i,
1449
+ e[:start_col].to_i,
1450
+ e[:end_line].to_i,
1451
+ e[:end_col].to_i
1452
+ ],
1453
+ kind: (e[:kind] || "expr").to_s,
1454
+ name: e[:name],
1455
+ sampled_values: sampled_values || [],
1456
+ types: sorted_type_counts(e[:types]),
1457
+ total: e[:total].to_i
1458
+ }
1459
+ end
1460
+
1461
+ def self.payload_json_for_script(payload)
1462
+ JSON.generate(payload)
1463
+ .gsub("</", "<\\/")
1464
+ .gsub("\u2028", "\\u2028")
1465
+ .gsub("\u2029", "\\u2029")
1466
+ end
1467
+
1468
+ def self.html_renderer_js
1469
+ @html_renderer_js ||= '(function() {
1470
+ const payloadEl = document.getElementById("lumitrace-payload");
1471
+ const app = document.getElementById("lumitrace-app");
1472
+ if (!payloadEl || !app) return;
1473
+
1474
+ const SL = 0;
1475
+ const SC = 1;
1476
+ const EL = 2;
1477
+ const EC = 3;
1478
+
1479
+ function escHtml(s) {
1480
+ return String(s)
1481
+ .replace(/&/g, "&amp;")
1482
+ .replace(/</g, "&lt;")
1483
+ .replace(/>/g, "&gt;")
1484
+ .replace(/\\"/g, "&quot;");
1485
+ }
1486
+
1487
+ function normalizeTypeCounts(types) {
1488
+ if (!types) return {};
1489
+ if (Array.isArray(types)) {
1490
+ const out = {};
1491
+ for (const t of types) {
1492
+ const key = String(t || "");
1493
+ if (!key) continue;
1494
+ out[key] = (out[key] || 0) + 1;
1495
+ }
1496
+ return out;
1497
+ }
1498
+ if (typeof types === "object") {
1499
+ const out = {};
1500
+ for (const [k, v] of Object.entries(types)) {
1501
+ const key = String(k || "");
1502
+ if (!key) continue;
1503
+ let count = Number(v) || 0;
1504
+ if (count <= 0) count = 1;
1505
+ out[key] = (out[key] || 0) + count;
1506
+ }
1507
+ return out;
1508
+ }
1509
+ const key = String(types || "");
1510
+ return key ? { [key]: 1 } : {};
1511
+ }
1512
+
1513
+ function typeListText(types, onlyIfMultiple) {
1514
+ const counts = normalizeTypeCounts(types);
1515
+ const entries = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
1516
+ if (onlyIfMultiple && entries.length <= 1) return null;
1517
+ if (entries.length === 0) return "(no types)";
1518
+ return "types: " + entries.map(([k, v]) => `${k}(${v})`).join(", ");
1519
+ }
1520
+
1521
+ function valueTypeName(v) {
1522
+ if (v === null) return "NilClass";
1523
+ if (Array.isArray(v)) return "Array";
1524
+ switch (typeof v) {
1525
+ case "number": return Number.isInteger(v) ? "Integer" : "Float";
1526
+ case "string": return "String";
1527
+ case "boolean": return "Boolean";
1528
+ case "undefined": return "NilClass";
1529
+ case "object": return "Object";
1530
+ default: return typeof v;
1531
+ }
1532
+ }
1533
+
1534
+ function formatValue(v, type) {
1535
+ const value = v == null ? "nil" : String(v);
1536
+ return `${value} (${type || valueTypeName(v)})`;
1537
+ }
1538
+
1539
+ function lastValueToPair(lastValue) {
1540
+ if (lastValue == null) return [null, null];
1541
+ if (typeof lastValue !== "object" || Array.isArray(lastValue)) return [lastValue, null];
1542
+ const type = lastValue.type || null;
1543
+ if (Object.prototype.hasOwnProperty.call(lastValue, "value")) return [lastValue.value, type];
1544
+ if (Object.prototype.hasOwnProperty.call(lastValue, "preview")) return [lastValue.preview, type];
1545
+ if (Object.prototype.hasOwnProperty.call(lastValue, "inspect")) return [lastValue.inspect, type];
1546
+ return [JSON.stringify(lastValue), type];
1547
+ }
1548
+
1549
+ function summarizeValues(values, total, allTypes) {
1550
+ if (!values || values.length === 0) {
1551
+ const multi = typeListText(allTypes, false);
1552
+ return multi || "";
1553
+ }
1347
1554
 
1555
+ const n = total == null ? values.length : Number(total);
1556
+ const lastVals = values.slice(-3);
1557
+ const firstIndex = n - lastVals.length + 1;
1558
+ const lines = [];
1559
+ const extra = n - lastVals.length;
1560
+ if (extra > 0) lines.push(`... (+${extra} more)`);
1561
+
1562
+ lastVals.forEach((v, i) => {
1563
+ const idx = firstIndex + i;
1564
+ const [valueText, typeText] = lastValueToPair(v);
1565
+ lines.push(`#${idx}: ${formatValue(valueText, typeText)}`);
1566
+ });
1567
+
1568
+ const multi = typeListText(allTypes, true);
1569
+ if (multi) lines.push(multi);
1570
+ return lines.join("\\n");
1571
+ }
1572
+
1573
+ function lineClassFor(expected, executed) {
1574
+ if (executed > 0) return " hit";
1575
+ if (expected > 0) return " miss";
1576
+ return "";
1577
+ }
1578
+
1579
+ function lineInRanges(line, ranges) {
1580
+ if (!ranges || ranges.length === 0) return true;
1581
+ return ranges.some((range) => {
1582
+ if (!Array.isArray(range) || range.length < 2) return false;
1583
+ const start = Number(range[0]);
1584
+ const end = Number(range[1]);
1585
+ return line >= start && line <= end;
1586
+ });
1587
+ }
1588
+
1589
+ function sourceLines(source) {
1590
+ const matches = String(source || "").match(/[^\\n]*\\n|[^\\n]+/g);
1591
+ if (!matches) return [];
1592
+ return matches.map((line) => line.endsWith("\\n") ? line.slice(0, -1) : line);
1593
+ }
1594
+
1595
+ function lineStatsForFile(trace) {
1596
+ const expectedByLine = Object.create(null);
1597
+ const executedByLine = Object.create(null);
1598
+ const seen = Object.create(null);
1599
+
1600
+ for (const event of trace || []) {
1601
+ const loc = event && event.location;
1602
+ if (!Array.isArray(loc) || loc.length < 4) continue;
1603
+ const sl = Number(loc[SL]);
1604
+ const el = Number(loc[EL]);
1605
+ if (!(sl > 0) || !(el > 0)) continue;
1606
+ const key = `${sl}:${loc[SC]}:${el}:${loc[EC]}`;
1607
+ if (seen[key]) continue;
1608
+ seen[key] = true;
1609
+ for (let line = sl; line <= el; line += 1) {
1610
+ expectedByLine[line] = (expectedByLine[line] || 0) + 1;
1611
+ if (Number(event.total) > 0) {
1612
+ executedByLine[line] = (executedByLine[line] || 0) + 1;
1613
+ }
1614
+ }
1615
+ }
1616
+
1617
+ return { expectedByLine, executedByLine };
1618
+ }
1619
+
1620
+ function expressionLineCoverageForTrace(trace) {
1621
+ const expectedLines = new Set();
1622
+ const executedLines = new Set();
1623
+ const seen = new Set();
1624
+
1625
+ for (const event of trace || []) {
1626
+ if (!event || event.kind === "arg") continue;
1627
+ const loc = event.location;
1628
+ if (!Array.isArray(loc) || loc.length < 4) continue;
1629
+
1630
+ const sl = Number(loc[SL]);
1631
+ const sc = Number(loc[SC]);
1632
+ const el = Number(loc[EL]);
1633
+ const ec = Number(loc[EC]);
1634
+ if (!(sl > 0) || !(el > 0)) continue;
1635
+
1636
+ const key = `${sl}:${sc}:${el}:${ec}`;
1637
+ if (seen.has(key)) continue;
1638
+ seen.add(key);
1639
+
1640
+ for (let line = sl; line <= el; line += 1) {
1641
+ expectedLines.add(line);
1642
+ if (Number(event.total) > 0) executedLines.add(line);
1643
+ }
1644
+ }
1645
+
1646
+ return {
1647
+ executed: executedLines.size,
1648
+ expected: expectedLines.size
1649
+ };
1650
+ }
1651
+
1652
+ function aggregateEventsForLine(trace, lineno, lineLen, fileIndex) {
1653
+ const buckets = new Map();
1654
+ const spans = [];
1655
+
1656
+ for (const event of trace || []) {
1657
+ const loc = event && event.location;
1658
+ if (!Array.isArray(loc) || loc.length < 4) continue;
1659
+ const sline = Number(loc[SL]);
1660
+ const scol = Number(loc[SC]);
1661
+ const eline = Number(loc[EL]);
1662
+ const ecol = Number(loc[EC]);
1663
+ if (lineno < sline || lineno > eline) continue;
1664
+
1665
+ let s;
1666
+ let t;
1667
+ let marker;
1668
+ if (sline === eline) {
1669
+ s = scol;
1670
+ t = ecol;
1671
+ marker = true;
1672
+ } else if (lineno === sline) {
1673
+ s = scol;
1674
+ t = lineLen;
1675
+ marker = false;
1676
+ } else if (lineno === eline) {
1677
+ s = 0;
1678
+ t = ecol;
1679
+ marker = true;
1680
+ } else {
1681
+ s = 0;
1682
+ t = lineLen;
1683
+ marker = false;
1684
+ }
1685
+
1686
+ if (!(t > s)) continue;
1687
+ spans.push({ start_col: s, end_col: t });
1688
+
1689
+ const keyId = `${fileIndex}:${sline}:${scol}:${eline}:${ecol}`;
1690
+ buckets.set(keyId, {
1691
+ key_id: keyId,
1692
+ start_col: s,
1693
+ end_col: t,
1694
+ marker,
1695
+ kind: event.kind || "expr",
1696
+ name: event.name || null,
1697
+ sampled_values: event.sampled_values || [],
1698
+ types: event.types || {},
1699
+ total: Number(event.total) || 0,
1700
+ suppress_miss: false
1701
+ });
1702
+ }
1703
+
1704
+ const out = Array.from(buckets.values());
1705
+ for (const b of out) {
1706
+ const depth = spans.filter((sp) => b.start_col >= sp.start_col && b.end_col <= sp.end_col).length;
1707
+ b.depth = Math.min(5, Math.max(1, depth));
1708
+ }
1709
+ out.sort((a, b) => a.start_col - b.start_col);
1710
+ return out;
1711
+ }
1712
+
1713
+ function renderLineHtml(lineText, events) {
1714
+ const opens = Object.create(null);
1715
+ const closes = Object.create(null);
1716
+
1717
+ for (const e of events) {
1718
+ const s = Number(e.start_col);
1719
+ const t = Number(e.end_col);
1720
+ if (!(t > s)) continue;
1721
+
1722
+ const values = e.sampled_values || [];
1723
+ const allTypes = e.types || {};
1724
+ const total = Number(e.total) || 0;
1725
+ const label = e.kind === "arg" && e.name ? `arg ${e.name}` : null;
1726
+
1727
+ let valueText;
1728
+ if (total === 0) {
1729
+ valueText = label ? `${label}: (not hit)` : "(not hit)";
1730
+ } else {
1731
+ const summary = summarizeValues(values, total, allTypes);
1732
+ if (label) {
1733
+ valueText = summary ? `${label}: ${summary}` : label;
1734
+ } else {
1735
+ valueText = summary;
1736
+ }
1737
+ }
1738
+
1739
+ const tooltipHtml = escHtml(valueText);
1740
+ const depthClass = `depth-${e.depth || 1}`;
1741
+ const missClass = total === 0 && !e.suppress_miss ? " miss" : "";
1742
+ const keyAttr = escHtml(e.key_id || "");
1743
+ const openTag = `<span class=\\"expr hit ${depthClass}${missClass}\\" data-key=\\"${keyAttr}\\">`;
1744
+
1745
+ let closeTag = "</span>";
1746
+ if (e.marker !== false) {
1747
+ let marker = "🔎";
1748
+ if (total === 0) marker = "∅";
1749
+ else if (e.kind === "arg") marker = "🧷";
1750
+
1751
+ let markerClass = "marker";
1752
+ if (total === 0 && !e.suppress_miss) markerClass = "marker miss";
1753
+ if (e.kind === "arg") markerClass += " arg";
1754
+ closeTag = `<span class=\\"${markerClass}\\" data-key=\\"${keyAttr}\\" aria-hidden=\\"true\\">${marker}<span class=\\"tooltip\\">${tooltipHtml}</span></span></span>`;
1755
+ }
1756
+
1757
+ const len = t - s;
1758
+ (opens[s] ||= []).push({ len, start: s, end: t, tag: openTag });
1759
+ (closes[t] ||= []).push({ len, start: s, end: t, tag: closeTag });
1760
+ }
1761
+
1762
+ const positions = Array.from(new Set([
1763
+ ...Object.keys(opens).map(Number),
1764
+ ...Object.keys(closes).map(Number)
1765
+ ])).sort((a, b) => a - b);
1766
+
1767
+ let out = "";
1768
+ let cursor = 0;
1769
+
1770
+ for (const pos of positions) {
1771
+ if (pos > cursor) out += escHtml(lineText.slice(cursor, pos));
1772
+ if (closes[pos]) {
1773
+ closes[pos]
1774
+ .slice()
1775
+ .sort((a, b) => (b.start - a.start) || (a.len - b.len))
1776
+ .forEach((c) => { out += c.tag; });
1777
+ }
1778
+ if (opens[pos]) {
1779
+ opens[pos]
1780
+ .slice()
1781
+ .sort((a, b) => b.end - a.end)
1782
+ .forEach((o) => { out += o.tag; });
1783
+ }
1784
+ cursor = pos;
1785
+ }
1786
+
1787
+ if (cursor < lineText.length) out += escHtml(lineText.slice(cursor));
1788
+ return out;
1789
+ }
1790
+
1791
+ function buildLineNode(lineno, lineText, lineClass, innerHtml, options) {
1792
+ const opts = options || {};
1793
+ const line = document.createElement("span");
1794
+ line.className = `line${lineClass}`;
1795
+ line.dataset.line = String(lineno);
1796
+ line.id = `line-${lineno}`;
1797
+
1798
+ const ln = opts.lineHref ? document.createElement("a") : document.createElement("span");
1799
+ ln.className = `ln${opts.lineHref ? " ln-link" : ""}`;
1800
+ ln.textContent = String(lineno);
1801
+ if (opts.lineHref) {
1802
+ ln.href = opts.lineHref(lineno);
1803
+ ln.title = `Link to line ${lineno}`;
1804
+ ln.addEventListener("click", (event) => {
1805
+ if (!opts.onLineClick) return;
1806
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
1807
+ event.preventDefault();
1808
+ opts.onLineClick(lineno);
1809
+ });
1810
+ }
1811
+ line.appendChild(ln);
1812
+ line.appendChild(document.createTextNode(" "));
1813
+
1814
+ if (innerHtml == null) {
1815
+ line.appendChild(document.createTextNode(lineText));
1816
+ } else {
1817
+ const wrapper = document.createElement("span");
1818
+ wrapper.innerHTML = innerHtml;
1819
+ while (wrapper.firstChild) line.appendChild(wrapper.firstChild);
1820
+ }
1821
+
1822
+ return line;
1823
+ }
1824
+
1825
+ function buildEllipsisNode() {
1826
+ const line = document.createElement("span");
1827
+ line.className = "line ellipsis";
1828
+ line.dataset.line = "...";
1829
+
1830
+ const ln = document.createElement("span");
1831
+ ln.className = "ln";
1832
+ ln.textContent = "...";
1833
+ line.appendChild(ln);
1834
+ return line;
1835
+ }
1836
+
1837
+ function renderFileSection(file, fileIndex, options) {
1838
+ const opts = options || {};
1839
+ const section = document.createElement("section");
1840
+ section.className = "file-section";
1841
+
1842
+ if (opts.includeTitle !== false) {
1843
+ const h2 = document.createElement("h2");
1844
+ h2.className = "file";
1845
+ h2.textContent = file.display_path || file.path || `file-${fileIndex + 1}`;
1846
+ section.appendChild(h2);
1847
+ }
1848
+
1849
+ const pre = document.createElement("pre");
1850
+ pre.className = "code";
1851
+ const code = document.createElement("code");
1852
+ pre.appendChild(code);
1853
+
1854
+ const lines = sourceLines(file.source || "");
1855
+ const ranges = Array.isArray(file.ranges) ? file.ranges : null;
1856
+ const trace = Array.isArray(file.trace) ? file.trace : [];
1857
+ const { expectedByLine, executedByLine } = lineStatsForFile(trace);
1858
+
1859
+ let prevLineno = null;
1860
+ let firstLineno = null;
1861
+ let lastLineno = null;
1862
+
1863
+ lines.forEach((lineText, idx) => {
1864
+ const lineno = idx + 1;
1865
+ if (!lineInRanges(lineno, ranges)) return;
1866
+ if (firstLineno == null) firstLineno = lineno;
1867
+ if (prevLineno != null && lineno > prevLineno + 1) {
1868
+ code.appendChild(buildEllipsisNode());
1869
+ }
1870
+
1871
+ const evs = aggregateEventsForLine(trace, lineno, lineText.length, fileIndex);
1872
+ const expected = expectedByLine[lineno] || 0;
1873
+ const executed = executedByLine[lineno] || 0;
1874
+ const lineClass = lineClassFor(expected, executed);
1875
+ if (expected > 0 && executed === 0) {
1876
+ evs.forEach((e) => { e.suppress_miss = true; });
1877
+ }
1878
+
1879
+ if (evs.length === 0) {
1880
+ code.appendChild(buildLineNode(lineno, lineText, lineClass, null, opts));
1881
+ } else {
1882
+ code.appendChild(buildLineNode(lineno, lineText, lineClass, renderLineHtml(lineText, evs), opts));
1883
+ }
1884
+
1885
+ prevLineno = lineno;
1886
+ lastLineno = lineno;
1887
+ });
1888
+
1889
+ if (firstLineno != null && firstLineno > 1) {
1890
+ code.insertBefore(buildEllipsisNode(), code.firstChild);
1891
+ }
1892
+ if (lastLineno != null && lastLineno < lines.length) {
1893
+ code.appendChild(buildEllipsisNode());
1894
+ }
1895
+
1896
+ section.appendChild(pre);
1897
+ return section;
1898
+ }
1899
+
1900
+ function bindMarkerHover(root) {
1901
+ root.querySelectorAll(".marker").forEach((marker) => {
1902
+ marker.addEventListener("mouseenter", () => {
1903
+ root.querySelectorAll(".expr.active").forEach((el) => el.classList.remove("active"));
1904
+ const key = marker.dataset.key;
1905
+ if (key) {
1906
+ root.querySelectorAll(`.expr[data-key=\\"${key}\\"]`).forEach((el) => el.classList.add("active"));
1907
+ } else {
1908
+ const expr = marker.closest(".expr");
1909
+ if (expr) expr.classList.add("active");
1910
+ }
1911
+ });
1912
+ marker.addEventListener("mouseleave", () => {
1913
+ const key = marker.dataset.key;
1914
+ if (key) {
1915
+ root.querySelectorAll(`.expr[data-key=\\"${key}\\"]`).forEach((el) => el.classList.remove("active"));
1916
+ } else {
1917
+ const expr = marker.closest(".expr");
1918
+ if (expr) expr.classList.remove("active");
1919
+ }
1920
+ });
1921
+ });
1922
+ }
1923
+
1924
+ function fileDisplayPath(file, idx) {
1925
+ return String((file && (file.display_path || file.path)) || `file-${idx + 1}`);
1926
+ }
1927
+
1928
+ function fileUrlKey(file, idx) {
1929
+ return fileDisplayPath(file, idx).replace(/\\\\/g, "/");
1930
+ }
1931
+
1932
+ function encodeHashFileKey(key) {
1933
+ return encodeURIComponent(String(key || "")).replace(/%2F/g, "/");
1934
+ }
1935
+
1936
+ function decodeHashFileKey(value) {
1937
+ try {
1938
+ return decodeURIComponent(String(value || ""));
1939
+ } catch (_e) {
1940
+ return String(value || "");
1941
+ }
1942
+ }
1943
+
1944
+ function parseHashStateFromLocation() {
1945
+ const raw = String(window.location.hash || "").replace(/^#/, "");
1946
+ if (!raw) return { fileKey: null, line: null };
1947
+
1948
+ if (!raw.includes("=")) {
1949
+ return { fileKey: decodeHashFileKey(raw), line: null };
1950
+ }
1951
+
1952
+ const params = new URLSearchParams(raw);
1953
+ const fileRaw = params.get("file");
1954
+ const lineRaw = params.get("line");
1955
+ const lineNum = lineRaw == null ? null : Number.parseInt(lineRaw, 10);
1956
+
1957
+ return {
1958
+ fileKey: fileRaw ? decodeHashFileKey(fileRaw) : null,
1959
+ line: Number.isFinite(lineNum) && lineNum > 0 ? lineNum : null
1960
+ };
1961
+ }
1962
+
1963
+ function buildHashForSelection(fileKey, line) {
1964
+ const parts = [];
1965
+ if (fileKey) parts.push(`file=${encodeHashFileKey(fileKey)}`);
1966
+ if (line && Number(line) > 0) parts.push(`line=${Number(line)}`);
1967
+ return `#${parts.join("&")}`;
1968
+ }
1969
+
1970
+ function setLocationHashSelection(fileKey, line) {
1971
+ const hash = buildHashForSelection(fileKey, line);
1972
+ if (window.location.hash === hash) return;
1973
+ if (window.history && typeof window.history.replaceState === "function") {
1974
+ try {
1975
+ window.history.replaceState(null, "", hash);
1976
+ return;
1977
+ } catch (_e) {
1978
+ // `about:srcdoc` (runv preview) can reject replaceState with a file:// hash URL.
1979
+ }
1980
+ }
1981
+ try {
1982
+ window.location.hash = hash;
1983
+ } catch (_e) {
1984
+ // Ignore hash update failures in restricted embedding contexts.
1985
+ }
1986
+ }
1987
+
1988
+ function buildFileTree(files) {
1989
+ const root = { dirs: new Map(), files: [] };
1990
+
1991
+ files.forEach((file, idx) => {
1992
+ const key = fileUrlKey(file, idx);
1993
+ const display = fileDisplayPath(file, idx).replace(/\\\\/g, "/");
1994
+ const parts = display.split("/").filter(Boolean);
1995
+ const filename = parts.pop() || display || `file-${idx + 1}`;
1996
+
1997
+ let node = root;
1998
+ let currentPath = "";
1999
+ for (const part of parts) {
2000
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
2001
+ if (!node.dirs.has(part)) {
2002
+ node.dirs.set(part, { name: part, path: currentPath, dirs: new Map(), files: [] });
2003
+ }
2004
+ node = node.dirs.get(part);
2005
+ }
2006
+ const coverage = expressionLineCoverageForTrace(Array.isArray(file.trace) ? file.trace : []);
2007
+ const coverageText = coverage.expected > 0 ? ` (${coverage.executed}/${coverage.expected})` : "";
2008
+ node.files.push({ label: filename, key, file, index: idx, path: display, coverageText });
2009
+ });
2010
+
2011
+ return root;
2012
+ }
2013
+
2014
+ function treeNodeContainsSelection(node, selectedKey) {
2015
+ if (!node) return false;
2016
+ if ((node.files || []).some((f) => f.key === selectedKey)) return true;
2017
+ for (const child of (node.dirs || new Map()).values()) {
2018
+ if (treeNodeContainsSelection(child, selectedKey)) return true;
2019
+ }
2020
+ return false;
2021
+ }
2022
+
2023
+ function renderFileTreeNode(node, selectedKey, onSelect, level) {
2024
+ const ul = document.createElement("ul");
2025
+ ul.className = "tree-list";
2026
+ ul.dataset.level = String(level || 0);
2027
+
2028
+ const dirs = Array.from((node.dirs || new Map()).values()).sort((a, b) => a.name.localeCompare(b.name));
2029
+ const files = Array.from(node.files || []).sort((a, b) => a.path.localeCompare(b.path));
2030
+
2031
+ dirs.forEach((dirNode) => {
2032
+ const li = document.createElement("li");
2033
+ li.className = "tree-dir";
2034
+
2035
+ const details = document.createElement("details");
2036
+ details.className = "tree-folder";
2037
+ details.open = true;
2038
+
2039
+ const summary = document.createElement("summary");
2040
+ summary.className = "tree-folder-label";
2041
+ summary.textContent = dirNode.name;
2042
+ details.appendChild(summary);
2043
+
2044
+ details.appendChild(renderFileTreeNode(dirNode, selectedKey, onSelect, (level || 0) + 1));
2045
+ li.appendChild(details);
2046
+ ul.appendChild(li);
2047
+ });
2048
+
2049
+ files.forEach((entry) => {
2050
+ const li = document.createElement("li");
2051
+ li.className = "tree-file";
2052
+
2053
+ const btn = document.createElement("button");
2054
+ btn.type = "button";
2055
+ btn.className = "tree-file-btn";
2056
+ const name = document.createElement("span");
2057
+ name.className = "tree-file-name";
2058
+ name.textContent = entry.label;
2059
+ btn.appendChild(name);
2060
+
2061
+ if (entry.coverageText) {
2062
+ const meta = document.createElement("span");
2063
+ meta.className = "tree-file-meta";
2064
+ meta.textContent = entry.coverageText;
2065
+ btn.appendChild(meta);
2066
+ }
2067
+
2068
+ btn.dataset.fileKey = entry.key;
2069
+ if (entry.key === selectedKey) {
2070
+ btn.classList.add("active");
2071
+ btn.setAttribute("aria-current", "page");
2072
+ }
2073
+ btn.addEventListener("click", () => onSelect(entry.key));
2074
+ li.appendChild(btn);
2075
+ ul.appendChild(li);
2076
+ });
2077
+
2078
+ return ul;
2079
+ }
2080
+
2081
+ function preferredInitialFileKey(files) {
2082
+ if (!files || files.length === 0) return null;
2083
+ const hashKey = parseHashStateFromLocation().fileKey;
2084
+ if (hashKey) {
2085
+ const byDisplay = files.find((file, idx) => fileUrlKey(file, idx) === hashKey);
2086
+ if (byDisplay) return fileUrlKey(byDisplay, files.indexOf(byDisplay));
2087
+
2088
+ const byPath = files.find((file) => String(file.path || "") === hashKey);
2089
+ if (byPath) return fileUrlKey(byPath, files.indexOf(byPath));
2090
+ }
2091
+ return fileUrlKey(files[0], 0);
2092
+ }
2093
+
2094
+ function preferredInitialLineNumber() {
2095
+ return parseHashStateFromLocation().line;
2096
+ }
2097
+
2098
+ function focusLineInViewer(viewer, lineno) {
2099
+ viewer.querySelectorAll(".line.line-target").forEach((el) => el.classList.remove("line-target"));
2100
+ if (!(lineno > 0)) return;
2101
+ const target = viewer.querySelector(`.line[data-line="${lineno}"]`);
2102
+ if (!target) return;
2103
+ target.classList.add("line-target");
2104
+ if (typeof target.scrollIntoView === "function") {
2105
+ target.scrollIntoView({ block: "center", inline: "nearest" });
2106
+ }
2107
+ }
2108
+
2109
+ function render(payload) {
2110
+ app.textContent = "";
2111
+
2112
+ const files = payload && Array.isArray(payload.files) ? payload.files : [];
2113
+ if (files.length === 0) {
2114
+ const empty = document.createElement("p");
2115
+ empty.className = "hint";
2116
+ empty.textContent = "No files to render.";
2117
+ app.appendChild(empty);
2118
+ return;
2119
+ }
2120
+
2121
+ const hint = document.createElement("div");
2122
+ hint.className = "hint";
2123
+ hint.textContent = "Hover highlighted text to see recorded values.";
2124
+ app.appendChild(hint);
2125
+
2126
+ const mode = document.createElement("div");
2127
+ mode.className = "mode";
2128
+ mode.textContent = payload && payload.meta && payload.meta.mode_text ? payload.meta.mode_text : "";
2129
+
2130
+ const commandText = payload && payload.meta && payload.meta.command ? String(payload.meta.command) : "";
2131
+ if (commandText) {
2132
+ const command = document.createElement("div");
2133
+ command.className = "command";
2134
+ command.textContent = `Command: ${commandText}`;
2135
+ app.appendChild(command);
2136
+ }
2137
+
2138
+ app.appendChild(mode);
2139
+
2140
+ const shell = document.createElement("div");
2141
+ shell.className = `report-layout${files.length <= 1 ? " single-file" : ""}`;
2142
+ app.appendChild(shell);
2143
+
2144
+ const sidebar = document.createElement("aside");
2145
+ sidebar.className = "report-sidebar";
2146
+ shell.appendChild(sidebar);
2147
+
2148
+ const treeTitle = document.createElement("div");
2149
+ treeTitle.className = "tree-title";
2150
+ treeTitle.textContent = `Files (${files.length})`;
2151
+ sidebar.appendChild(treeTitle);
2152
+
2153
+ const treeMount = document.createElement("div");
2154
+ treeMount.className = "tree-scroll";
2155
+ sidebar.appendChild(treeMount);
2156
+
2157
+ const main = document.createElement("section");
2158
+ main.className = "report-main";
2159
+ shell.appendChild(main);
2160
+
2161
+ const mainHead = document.createElement("div");
2162
+ mainHead.className = "report-main-head";
2163
+ main.appendChild(mainHead);
2164
+
2165
+ const currentPath = document.createElement("div");
2166
+ currentPath.className = "current-file";
2167
+ mainHead.appendChild(currentPath);
2168
+
2169
+ const viewer = document.createElement("div");
2170
+ viewer.className = "report-viewer";
2171
+ main.appendChild(viewer);
2172
+
2173
+ const fileKeyToEntry = new Map();
2174
+ files.forEach((file, idx) => fileKeyToEntry.set(fileUrlKey(file, idx), { file, idx }));
2175
+
2176
+ let selectedKey = preferredInitialFileKey(files);
2177
+ let selectedLine = preferredInitialLineNumber();
2178
+
2179
+ function renderTree() {
2180
+ treeMount.textContent = "";
2181
+ const treeRoot = buildFileTree(files);
2182
+ treeMount.appendChild(renderFileTreeNode(treeRoot, selectedKey, (key) => selectFile(key, true, null), 0));
2183
+ }
2184
+
2185
+ function renderSelectedFile() {
2186
+ viewer.textContent = "";
2187
+ const entry = fileKeyToEntry.get(selectedKey);
2188
+ if (!entry) return;
2189
+
2190
+ currentPath.textContent = fileDisplayPath(entry.file, entry.idx);
2191
+
2192
+ viewer.appendChild(renderFileSection(entry.file, entry.idx, {
2193
+ includeTitle: false,
2194
+ lineHref: (lineno) => buildHashForSelection(selectedKey, lineno),
2195
+ onLineClick: (lineno) => selectLine(lineno, true)
2196
+ }));
2197
+ bindMarkerHover(viewer);
2198
+ focusLineInViewer(viewer, selectedLine);
2199
+ }
2200
+
2201
+ function selectLine(lineno, updateHash) {
2202
+ const n = Number(lineno);
2203
+ selectedLine = Number.isFinite(n) && n > 0 ? n : null;
2204
+ renderSelectedFile();
2205
+ if (updateHash) setLocationHashSelection(selectedKey, selectedLine);
2206
+ }
2207
+
2208
+ function selectFile(key, updateHash, nextLine) {
2209
+ if (!fileKeyToEntry.has(key)) return;
2210
+ if (arguments.length >= 3) {
2211
+ selectedLine = nextLine && Number(nextLine) > 0 ? Number(nextLine) : null;
2212
+ } else if (key !== selectedKey) {
2213
+ selectedLine = null;
2214
+ }
2215
+ selectedKey = key;
2216
+ renderTree();
2217
+ renderSelectedFile();
2218
+ if (updateHash) setLocationHashSelection(key, selectedLine);
2219
+ }
2220
+
2221
+ window.addEventListener("hashchange", () => {
2222
+ const state = parseHashStateFromLocation();
2223
+ if (state.fileKey && state.fileKey !== selectedKey && fileKeyToEntry.has(state.fileKey)) {
2224
+ selectFile(state.fileKey, false, state.line);
2225
+ return;
2226
+ }
2227
+ if (state.fileKey === selectedKey || (!state.fileKey && selectedKey)) {
2228
+ selectedLine = state.line;
2229
+ renderSelectedFile();
2230
+ }
2231
+ });
2232
+
2233
+ selectFile(selectedKey, true);
2234
+ }
2235
+
2236
+ try {
2237
+ const payload = JSON.parse(payloadEl.textContent || "{}");
2238
+ render(payload);
2239
+ } catch (error) {
2240
+ app.textContent = `Failed to render Lumitrace HTML: ${error}`;
2241
+ }
2242
+ })();'
2243
+ end
2244
+
2245
+ def self.html_report_styles
2246
+ <<~CSS
2247
+ body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
2248
+ .report-layout { display: grid; grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); gap: 16px; align-items: start; }
2249
+ .report-layout.single-file { grid-template-columns: minmax(0, 1fr); }
2250
+ .report-sidebar { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 12px; position: sticky; top: 16px; max-height: calc(100vh - 48px); overflow: hidden; }
2251
+ .report-layout.single-file .report-sidebar { display: none; }
2252
+ .tree-title { color: #444; font-size: 12px; margin-bottom: 8px; }
2253
+ .tree-scroll { overflow: auto; max-height: calc(100vh - 96px); }
2254
+ .tree-list { list-style: none; margin: 0; padding: 0; }
2255
+ .tree-list[data-level]:not([data-level="0"]) { margin-left: 14px; border-left: 1px dashed #e5dfd0; padding-left: 8px; }
2256
+ .tree-dir, .tree-file { margin: 2px 0; }
2257
+ .tree-folder { }
2258
+ .tree-folder-label { cursor: pointer; color: #4d473f; user-select: none; }
2259
+ .tree-folder-label::marker { color: #999; }
2260
+ .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; }
2261
+ .tree-file-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2262
+ .tree-file-meta { color: #6f6a62; font-size: 12px; white-space: nowrap; }
2263
+ .tree-file-btn:hover { background: #fff2c6; }
2264
+ .tree-file-btn.active { background: #f0ffe7; color: #1b5e3d; }
2265
+ .tree-file-btn.active .tree-file-meta { color: #1b5e3d; }
2266
+ .report-main { min-width: 0; }
2267
+ .report-main-head { display: flex; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 8px; }
2268
+ .current-file { color: #333; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2269
+ .report-viewer { min-width: 0; }
2270
+ .file-section { min-width: 0; }
2271
+ .code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
2272
+ .line { display: block; box-sizing: border-box; padding: 2px 8px; }
2273
+ .line:hover { background: #fff2c6; }
2274
+ .line.hit { background: #f0ffe7; }
2275
+ .line.miss { background: #ffecec; }
2276
+ .line.line-target { box-shadow: inset 3px 0 #2f6f8e; background: #e9f4fb; }
2277
+ .line.ellipsis { color: #999; }
2278
+ .ln { display: inline-block; width: 3em; color: #888; user-select: none; }
2279
+ .ln-link { color: inherit; text-decoration: none; }
2280
+ .ln-link:hover { text-decoration: underline; color: #2f6f8e; }
2281
+ .hint { color: #666; margin-bottom: 4px; }
2282
+ .command { color: #555; margin-bottom: 4px; font-size: 12px; overflow-wrap: anywhere; }
2283
+ .mode { color: #444; margin-bottom: 8px; }
2284
+ .report-footer { margin-top: 16px; color: #666; font-size: 12px; }
2285
+ .report-footer a { color: #2f6f8e; text-decoration: none; }
2286
+ .report-footer a:hover { text-decoration: underline; }
2287
+ .file { margin: 24px 0 8px; font-size: 16px; color: #333; }
2288
+ .expr { position: relative; display: inline-block; padding-bottom: 1px; }
2289
+ .expr.hit { }
2290
+ .expr.depth-1 { --hl: #7fbf7f; }
2291
+ .expr.depth-2 { --hl: #6fa8ff; }
2292
+ .expr.depth-3 { --hl: #ffb347; }
2293
+ .expr.depth-4 { --hl: #d78bff; }
2294
+ .expr.depth-5 { --hl: #ff6f91; }
2295
+ .expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
2296
+ .expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
2297
+ .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; }
2298
+ .marker.miss { color: #c07070; }
2299
+ .marker.arg { color: #2f6f8e; }
2300
+ .marker .tooltip {
2301
+ display: none;
2302
+ position: absolute;
2303
+ left: 0;
2304
+ bottom: 100%;
2305
+ margin-bottom: 6px;
2306
+ background: #2b2b2b;
2307
+ color: #fff;
2308
+ padding: 4px 6px;
2309
+ border-radius: 4px;
2310
+ font-size: 12px;
2311
+ white-space: pre;
2312
+ min-width: 16ch;
2313
+ max-width: 90vw;
2314
+ overflow-x: auto;
2315
+ overflow-y: hidden;
2316
+ z-index: 10;
2317
+ pointer-events: auto;
2318
+ }
2319
+ .marker:hover .tooltip,
2320
+ .marker:focus-within .tooltip,
2321
+ .marker .tooltip:hover { display: block; }
2322
+ .noscript { color: #666; }
2323
+ @media (max-width: 900px) {
2324
+ body { padding: 16px; }
2325
+ .report-layout { grid-template-columns: 1fr; }
2326
+ .report-sidebar { position: static; max-height: none; }
2327
+ .tree-scroll { max-height: 220px; }
2328
+ .report-main-head { flex-direction: column; align-items: flex-start; }
2329
+ }
2330
+ CSS
2331
+ end
2332
+
2333
+ def self.footer_version_suffix
2334
+ return "" unless defined?(Lumitrace::VERSION) && Lumitrace::VERSION
2335
+ " v#{Lumitrace::VERSION}"
2336
+ end
2337
+
2338
+ def self.render_payload_html(payload)
1348
2339
  <<~HTML
1349
2340
  <!doctype html>
1350
2341
  <html>
@@ -1352,71 +2343,22 @@ module GenerateResultedHtml
1352
2343
  <meta charset="utf-8">
1353
2344
  <title>Recorded Result View</title>
1354
2345
  <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; }
2346
+ #{html_report_styles}
1399
2347
  </style>
1400
2348
  </head>
1401
2349
  <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>
2350
+ <div id="lumitrace-app"></div>
2351
+ <div class="report-footer">Generated by <a href="https://ko1.github.io/lumitrace/" target="_blank" rel="noopener noreferrer">lumitrace</a>#{footer_version_suffix}.</div>
2352
+ <noscript><p class="noscript">Lumitrace HTML report requires JavaScript to render the source and trace view.</p></noscript>
2353
+ <script id="lumitrace-payload" type="application/json">#{payload_json_for_script(payload)}#{'</scr' + 'ipt>'}
2354
+ <script>
2355
+ #{html_renderer_js}
2356
+ #{'</scr' + 'ipt>'}
1407
2357
  </body>
1408
2358
  </html>
1409
2359
  HTML
1410
2360
  end
1411
2361
 
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
2362
  def self.detect_collect_mode(events)
1421
2363
  arr = Array(events)
1422
2364
  return "history" if arr.any? { |e| e.key?(:sampled_values) || e.key?("sampled_values") }
@@ -1566,6 +2508,7 @@ module GenerateResultedHtml
1566
2508
  def self.comment_value_with_total_for_line(events)
1567
2509
  best = best_event_for_line(events)
1568
2510
  return nil unless best
2511
+ return nil if best[:total].to_i <= 0
1569
2512
 
1570
2513
  sampled_last = best[:sampled_values]&.last
1571
2514
  v, t = last_value_to_pair(sampled_last)
@@ -1669,9 +2612,16 @@ module GenerateResultedHtml
1669
2612
 
1670
2613
  next if t <= s
1671
2614
  spans << { start_col: s, end_col: t }
1672
- key_id = e[:key].join(":")
1673
- buckets[e[:key]] = {
1674
- key: e[:key],
2615
+ event_key = e[:key] || [
2616
+ e[:file],
2617
+ e[:start_line].to_i,
2618
+ e[:start_col].to_i,
2619
+ e[:end_line].to_i,
2620
+ e[:end_col].to_i
2621
+ ]
2622
+ key_id = event_key.join(":")
2623
+ buckets[event_key] = {
2624
+ key: event_key,
1675
2625
  key_id: key_id,
1676
2626
  start_col: s,
1677
2627
  end_col: t,
@@ -1799,34 +2749,6 @@ module GenerateResultedHtml
1799
2749
  ranges.any? { |(s, e)| line >= s && line <= e }
1800
2750
  end
1801
2751
 
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
2752
  def self.line_stats(source, ranges, events, filename)
1831
2753
  expected_by_line = Hash.new(0)
1832
2754
  RecordInstrument.collect_locations_from_source(source, ranges || []).each do |loc|
@@ -1850,293 +2772,130 @@ module GenerateResultedHtml
1850
2772
  [expected_by_line, executed_by_line]
1851
2773
  end
1852
2774
 
1853
- def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil)
2775
+ def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil, logger: nil, command_text: nil)
1854
2776
  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|
2777
+ render_all_from_events(
2778
+ raw_events,
2779
+ root: root,
2780
+ ranges_by_file: ranges_by_file,
2781
+ collect_mode: collect_mode,
2782
+ max_samples: max_samples,
2783
+ logger: logger,
2784
+ command_text: command_text
2785
+ )
2786
+ end
2787
+
2788
+ 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)
2789
+ normalized = time_step("normalize_events", logger) { normalize_events(events) }
2790
+ render_all_from_normalized_events(
2791
+ normalized,
2792
+ root: root,
2793
+ ranges_by_file: ranges_by_file,
2794
+ collect_mode: collect_mode,
2795
+ max_samples: max_samples,
2796
+ logger: logger,
2797
+ command_text: command_text
2798
+ )
2799
+ end
2800
+
2801
+ 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)
2802
+ total_start = monotonic_now
2803
+
2804
+ mode_info = time_step("resolve_mode", logger) do
2805
+ resolve_mode_info(events, collect_mode: collect_mode, max_samples: max_samples)
2806
+ end
2807
+ by_file = time_step("group_by_file", logger) { events.group_by { |e| e[:file] } }
2808
+ ranges_by_file = time_step("normalize_ranges_by_file", logger) { normalize_ranges_by_file(ranges_by_file) }
2809
+
2810
+ files = []
2811
+ by_file.keys.sort.each do |path|
1867
2812
  next unless File.exist?(path)
2813
+ file_start = monotonic_now
2814
+ read_start = logger ? monotonic_now : nil
1868
2815
  src = File.read(path)
2816
+ read_ms = read_start ? (monotonic_now - read_start) * 1000.0 : nil
1869
2817
  if ranges_by_file
1870
2818
  next unless ranges_by_file.key?(path)
1871
2819
  ranges = ranges_by_file[path] || []
1872
2820
  else
1873
2821
  ranges = nil
1874
2822
  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"
2823
+ rel = path.start_with?(root) ? path.sub(root + File::SEPARATOR, "") : path
2824
+ select_start = logger ? monotonic_now : nil
2825
+ file_events = by_file[path] || []
2826
+ select_ms = select_start ? (monotonic_now - select_start) * 1000.0 : nil
2827
+ payload_file_start = logger ? monotonic_now : nil
2828
+ files << build_html_payload_file(
2829
+ path: path,
2830
+ display_path: rel,
2831
+ source: src,
2832
+ ranges: ranges,
2833
+ trace_events: file_events,
2834
+ logger: logger
2835
+ )
2836
+ payload_file_ms = payload_file_start ? (monotonic_now - payload_file_start) * 1000.0 : nil
2837
+ if logger
2838
+ elapsed_ms = (monotonic_now - file_start) * 1000.0
2839
+ 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
2840
  end
2841
+ end
1911
2842
 
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")
2843
+ payload = time_step("build_payload", logger) { build_html_payload(mode_info: mode_info, files: files, command_text: command_text) }
2844
+ html = time_step("render_payload_html", logger) { render_payload_html(payload) }
2845
+ if logger
2846
+ total_ms = (monotonic_now - total_start) * 1000.0
2847
+ logger.call(format("html render: total files=%d events=%d html_bytes=%d %.1fms", files.length, events.length, html.bytesize, total_ms))
2848
+ end
2849
+ html
2850
+ end
1920
2851
 
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
2852
+ def self.render_source_from_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil, command_text: nil)
2853
+ render_source_from_normalized_events(
2854
+ source,
2855
+ normalize_events(events),
2856
+ filename: filename,
2857
+ ranges: ranges,
2858
+ collect_mode: collect_mode,
2859
+ max_samples: max_samples,
2860
+ command_text: command_text
2861
+ )
2005
2862
  end
2006
2863
 
2007
- def self.render_source_from_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil)
2864
+ def self.render_source_from_normalized_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil, command_text: nil)
2008
2865
  mode_info = resolve_mode_info(events, collect_mode: collect_mode, max_samples: max_samples)
2009
- events = normalize_events(events)
2010
2866
  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)
2867
+ target_events = events.select { |e| e[:file] == filename }
2013
2868
 
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
2869
+ payload = build_html_payload(
2870
+ mode_info: mode_info,
2871
+ command_text: command_text,
2872
+ files: [
2873
+ build_html_payload_file(
2874
+ path: filename,
2875
+ display_path: filename,
2876
+ source: source,
2877
+ ranges: ranges,
2878
+ trace_events: target_events
2879
+ )
2880
+ ]
2881
+ )
2048
2882
 
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
2883
+ render_payload_html(payload)
2136
2884
  end
2137
2885
 
2138
2886
  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)
2887
+ render_text_from_normalized_events(
2888
+ source,
2889
+ normalize_events(events),
2890
+ filename: filename,
2891
+ ranges: ranges,
2892
+ with_header: with_header,
2893
+ header_label: header_label,
2894
+ tty: tty
2895
+ )
2896
+ end
2897
+
2898
+ def self.render_text_from_normalized_events(source, events, filename: "script.rb", ranges: nil, with_header: true, header_label: nil, tty: nil)
2140
2899
  ranges = normalize_ranges(ranges)
2141
2900
  target_events = events.select { |e| e[:file] == filename }
2142
2901
  term_width = tty ? terminal_width : nil
@@ -2158,7 +2917,7 @@ module GenerateResultedHtml
2158
2917
  ]
2159
2918
  next if seen[key]
2160
2919
  seen[key] = true
2161
- executed_by_line[line] += 1 if line
2920
+ executed_by_line[line] += 1 if line && (e[:total] || e["total"]).to_i > 0
2162
2921
  end
2163
2922
 
2164
2923
  out = +""
@@ -2259,7 +3018,10 @@ module GenerateResultedHtml
2259
3018
  end
2260
3019
 
2261
3020
  def self.render_text_all_from_events(events, root: Dir.pwd, ranges_by_file: nil, tty: nil)
2262
- events = normalize_events(events)
3021
+ render_text_all_from_normalized_events(normalize_events(events), root: root, ranges_by_file: ranges_by_file, tty: tty)
3022
+ end
3023
+
3024
+ def self.render_text_all_from_normalized_events(events, root: Dir.pwd, ranges_by_file: nil, tty: nil)
2263
3025
  by_file = events.group_by { |e| e[:file] }
2264
3026
  ranges_by_file = normalize_ranges_by_file(ranges_by_file)
2265
3027
 
@@ -2273,7 +3035,7 @@ module GenerateResultedHtml
2273
3035
  ranges = nil
2274
3036
  end
2275
3037
  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)
3038
+ render_text_from_normalized_events(src, events, filename: path, ranges: ranges, with_header: true, header_label: rel, tty: tty)
2277
3039
  end.compact
2278
3040
 
2279
3041
  header = "\n=== Lumitrace Results (text) ===\n\n"