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.
@@ -3,6 +3,20 @@ require_relative "record_instrument"
3
3
 
4
4
  module Lumitrace
5
5
  module GenerateResultedHtml
6
+ RENDERER_JS_PATH = File.expand_path("generate_resulted_html_renderer.js", __dir__)
7
+
8
+ def self.monotonic_now
9
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+ end
11
+
12
+ def self.time_step(label, logger = nil)
13
+ start = monotonic_now
14
+ result = yield
15
+ elapsed_ms = (monotonic_now - start) * 1000.0
16
+ logger&.call(format("html render: %s %.1fms", label, elapsed_ms))
17
+ result
18
+ end
19
+
6
20
  def self.render(source_path, events_path, ranges: nil, collect_mode: nil, max_samples: nil)
7
21
  unless File.exist?(events_path)
8
22
  abort "missing #{events_path}"
@@ -12,50 +26,197 @@ module GenerateResultedHtml
12
26
  end
13
27
 
14
28
  raw_events = JSON.parse(File.read(events_path))
29
+ src = File.read(source_path)
15
30
  mode_info = resolve_mode_info(raw_events, collect_mode: collect_mode, max_samples: max_samples)
16
- events = normalize_events(raw_events)
17
- events = add_missing_events(events, File.read(source_path), source_path, ranges)
31
+ normalized_ranges = normalize_ranges(ranges)
32
+ events = normalize_events(raw_events).select { |e| e[:file] == source_path }
33
+
34
+ payload = build_html_payload(
35
+ mode_info: mode_info,
36
+ files: [
37
+ build_html_payload_file(
38
+ path: source_path,
39
+ display_path: File.basename(source_path),
40
+ source: src,
41
+ ranges: normalized_ranges,
42
+ trace_events: events
43
+ )
44
+ ]
45
+ )
18
46
 
19
- src = File.read(source_path)
20
- src_lines = src.lines
21
- ranges = normalize_ranges(ranges)
22
- expected_by_line, executed_by_line = line_stats(src, ranges, events, source_path)
47
+ render_payload_html(payload)
48
+ end
23
49
 
24
- html_lines = []
25
- prev_lineno = nil
26
- first_lineno = nil
27
- last_lineno = nil
28
- src_lines.each_with_index do |line, idx|
29
- lineno = idx + 1
30
- next if ranges && !line_in_ranges?(lineno, ranges)
31
- first_lineno ||= lineno
32
- if prev_lineno && lineno > prev_lineno + 1
33
- html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
34
- end
35
- line_text = line.chomp
36
- evs = aggregate_events_for_line(events, lineno, line_text.length)
37
- expected = expected_by_line[lineno]
38
- executed = executed_by_line[lineno]
39
- line_class = line_class_for(expected, executed)
40
- if expected > 0 && executed == 0
41
- evs.each { |e| e[:suppress_miss] = true }
42
- end
43
- if evs.empty?
44
- html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{esc(line_text)}</span>\n"
45
- else
46
- rendered = render_line_with_events(line_text, evs)
47
- html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{rendered}</span>\n"
48
- end
49
- prev_lineno = lineno
50
- last_lineno = lineno
51
- end
52
- if first_lineno && first_lineno > 1
53
- html_lines.unshift("<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n")
50
+ def self.esc(s)
51
+ s.to_s
52
+ .gsub("&", "&amp;")
53
+ .gsub("<", "&lt;")
54
+ .gsub(">", "&gt;")
55
+ .gsub('"', "&quot;")
56
+ end
57
+
58
+ def self.build_html_payload(mode_info:, files:, command_text: nil)
59
+ meta = {
60
+ mode: mode_info[:mode],
61
+ mode_text: mode_info[:text],
62
+ max_samples: mode_info[:max_samples]
63
+ }
64
+ meta[:command] = command_text if command_text && !command_text.to_s.empty?
65
+ {
66
+ version: 1,
67
+ meta: meta,
68
+ files: files
69
+ }
70
+ end
71
+
72
+ def self.build_html_payload_file(path:, display_path:, source:, ranges:, trace_events:, logger: nil)
73
+ sort_start = logger ? monotonic_now : nil
74
+ sorted_events = Array(trace_events).sort_by do |e|
75
+ [e[:start_line].to_i, e[:start_col].to_i, e[:end_line].to_i, e[:end_col].to_i]
54
76
  end
55
- if last_lineno && last_lineno < src_lines.length
56
- html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
77
+ sort_ms = sort_start ? (monotonic_now - sort_start) * 1000.0 : nil
78
+
79
+ map_start = logger ? monotonic_now : nil
80
+ trace_payload = sorted_events.map { |e| event_to_html_trace_payload(e) }
81
+ map_ms = map_start ? (monotonic_now - map_start) * 1000.0 : nil
82
+ if logger
83
+ logger.call(format("html render: payload_file %s sort=%.1fms map=%.1fms events=%d", display_path, sort_ms, map_ms, sorted_events.length))
57
84
  end
58
85
 
86
+ {
87
+ path: path,
88
+ display_path: display_path,
89
+ source: source,
90
+ ranges: ranges,
91
+ trace: trace_payload
92
+ }
93
+ end
94
+
95
+ def self.event_to_html_trace_payload(e)
96
+ sampled_values = e[:sampled_values]
97
+ if (sampled_values.nil? || sampled_values.empty?) && e[:last_value]
98
+ sampled_values = [e[:last_value]]
99
+ end
100
+ {
101
+ location: [
102
+ e[:start_line].to_i,
103
+ e[:start_col].to_i,
104
+ e[:end_line].to_i,
105
+ e[:end_col].to_i
106
+ ],
107
+ kind: (e[:kind] || "expr").to_s,
108
+ name: e[:name],
109
+ sampled_values: sampled_values || [],
110
+ types: sorted_type_counts(e[:types]),
111
+ total: e[:total].to_i
112
+ }
113
+ end
114
+
115
+ def self.payload_json_for_script(payload)
116
+ JSON.generate(payload)
117
+ .gsub("</", "<\\/")
118
+ .gsub("\u2028", "\\u2028")
119
+ .gsub("\u2029", "\\u2029")
120
+ end
121
+
122
+ def self.html_renderer_js
123
+ @html_renderer_js ||= File.read(RENDERER_JS_PATH)
124
+ end
125
+
126
+ def self.html_report_styles
127
+ <<~CSS
128
+ body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
129
+ .report-layout { display: grid; grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); gap: 16px; align-items: start; }
130
+ .report-layout.single-file { grid-template-columns: minmax(0, 1fr); }
131
+ .report-sidebar { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 12px; position: sticky; top: 16px; max-height: calc(100vh - 48px); overflow: hidden; }
132
+ .report-layout.single-file .report-sidebar { display: none; }
133
+ .tree-title { color: #444; font-size: 12px; margin-bottom: 8px; }
134
+ .tree-scroll { overflow: auto; max-height: calc(100vh - 96px); }
135
+ .tree-list { list-style: none; margin: 0; padding: 0; }
136
+ .tree-list[data-level]:not([data-level="0"]) { margin-left: 14px; border-left: 1px dashed #e5dfd0; padding-left: 8px; }
137
+ .tree-dir, .tree-file { margin: 2px 0; }
138
+ .tree-folder { }
139
+ .tree-folder-label { cursor: pointer; color: #4d473f; user-select: none; }
140
+ .tree-folder-label::marker { color: #999; }
141
+ .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; }
142
+ .tree-file-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
143
+ .tree-file-meta { color: #6f6a62; font-size: 12px; white-space: nowrap; }
144
+ .tree-file-btn:hover { background: #fff2c6; }
145
+ .tree-file-btn.active { background: #f0ffe7; color: #1b5e3d; }
146
+ .tree-file-btn.active .tree-file-meta { color: #1b5e3d; }
147
+ .report-main { min-width: 0; }
148
+ .report-main-head { display: flex; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 8px; }
149
+ .current-file { color: #333; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
150
+ .report-viewer { min-width: 0; }
151
+ .file-section { min-width: 0; }
152
+ .code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
153
+ .line { display: block; box-sizing: border-box; padding: 2px 8px; }
154
+ .line:hover { background: #fff2c6; }
155
+ .line.hit { background: #f0ffe7; }
156
+ .line.miss { background: #ffecec; }
157
+ .line.line-target { box-shadow: inset 3px 0 #2f6f8e; background: #e9f4fb; }
158
+ .line.ellipsis { color: #999; }
159
+ .ln { display: inline-block; width: 3em; color: #888; user-select: none; }
160
+ .ln-link { color: inherit; text-decoration: none; }
161
+ .ln-link:hover { text-decoration: underline; color: #2f6f8e; }
162
+ .hint { color: #666; margin-bottom: 4px; }
163
+ .command { color: #555; margin-bottom: 4px; font-size: 12px; overflow-wrap: anywhere; }
164
+ .mode { color: #444; margin-bottom: 8px; }
165
+ .report-footer { margin-top: 16px; color: #666; font-size: 12px; }
166
+ .report-footer a { color: #2f6f8e; text-decoration: none; }
167
+ .report-footer a:hover { text-decoration: underline; }
168
+ .file { margin: 24px 0 8px; font-size: 16px; color: #333; }
169
+ .expr { position: relative; display: inline-block; padding-bottom: 1px; }
170
+ .expr.hit { }
171
+ .expr.depth-1 { --hl: #7fbf7f; }
172
+ .expr.depth-2 { --hl: #6fa8ff; }
173
+ .expr.depth-3 { --hl: #ffb347; }
174
+ .expr.depth-4 { --hl: #d78bff; }
175
+ .expr.depth-5 { --hl: #ff6f91; }
176
+ .expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
177
+ .expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
178
+ .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; }
179
+ .marker.miss { color: #c07070; }
180
+ .marker.arg { color: #2f6f8e; }
181
+ .marker .tooltip {
182
+ display: none;
183
+ position: absolute;
184
+ left: 0;
185
+ bottom: 100%;
186
+ margin-bottom: 6px;
187
+ background: #2b2b2b;
188
+ color: #fff;
189
+ padding: 4px 6px;
190
+ border-radius: 4px;
191
+ font-size: 12px;
192
+ white-space: pre;
193
+ min-width: 16ch;
194
+ max-width: 90vw;
195
+ overflow-x: auto;
196
+ overflow-y: hidden;
197
+ z-index: 10;
198
+ pointer-events: auto;
199
+ }
200
+ .marker:hover .tooltip,
201
+ .marker:focus-within .tooltip,
202
+ .marker .tooltip:hover { display: block; }
203
+ .noscript { color: #666; }
204
+ @media (max-width: 900px) {
205
+ body { padding: 16px; }
206
+ .report-layout { grid-template-columns: 1fr; }
207
+ .report-sidebar { position: static; max-height: none; }
208
+ .tree-scroll { max-height: 220px; }
209
+ .report-main-head { flex-direction: column; align-items: flex-start; }
210
+ }
211
+ CSS
212
+ end
213
+
214
+ def self.footer_version_suffix
215
+ return "" unless defined?(Lumitrace::VERSION) && Lumitrace::VERSION
216
+ " v#{Lumitrace::VERSION}"
217
+ end
218
+
219
+ def self.render_payload_html(payload)
59
220
  <<~HTML
60
221
  <!doctype html>
61
222
  <html>
@@ -63,71 +224,22 @@ module GenerateResultedHtml
63
224
  <meta charset="utf-8">
64
225
  <title>Recorded Result View</title>
65
226
  <style>
66
- body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
67
- .code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
68
- .line { display: inline-block; width: 100%; box-sizing: border-box; padding: 2px 8px; }
69
- .line:hover { background: #fff2c6; }
70
- .line.hit { background: #f0ffe7; }
71
- .line.miss { background: #ffecec; }
72
- .line.ellipsis { color: #999; }
73
- .ln { display: inline-block; width: 3em; color: #888; user-select: none; }
74
- .hint { color: #666; margin-bottom: 4px; }
75
- .mode { color: #444; margin-bottom: 8px; }
76
- .expr { position: relative; display: inline-block; padding-bottom: 1px; }
77
- .expr.hit { }
78
- .expr.depth-1 { --hl: #7fbf7f; }
79
- .expr.depth-2 { --hl: #6fa8ff; }
80
- .expr.depth-3 { --hl: #ffb347; }
81
- .expr.depth-4 { --hl: #d78bff; }
82
- .expr.depth-5 { --hl: #ff6f91; }
83
- .expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
84
- .expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
85
- .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; }
86
- .marker.miss { color: #c07070; }
87
- .marker.arg { color: #2f6f8e; }
88
- .marker .tooltip {
89
- display: none;
90
- position: absolute;
91
- left: 0;
92
- top: 100%;
93
- margin-top: 4px;
94
- background: #2b2b2b;
95
- color: #fff;
96
- padding: 4px 6px;
97
- border-radius: 4px;
98
- font-size: 12px;
99
- white-space: pre;
100
- min-width: 16ch;
101
- max-width: 90vw;
102
- overflow-x: auto;
103
- overflow-y: hidden;
104
- z-index: 10;
105
- pointer-events: auto;
106
- }
107
- .marker:hover .tooltip,
108
- .marker:focus-within .tooltip,
109
- .marker .tooltip:hover { display: block; }
227
+ #{html_report_styles}
110
228
  </style>
111
229
  </head>
112
230
  <body>
113
- <div class="hint">Hover highlighted text to see recorded values.</div>
114
- <div class="mode">#{esc(mode_info[:text])}</div>
115
- <pre class="code"><code>
116
- #{html_lines.join("")}
117
- </code></pre>
231
+ <div id="lumitrace-app"></div>
232
+ <div class="report-footer">Generated by <a href="https://ko1.github.io/lumitrace/" target="_blank" rel="noopener noreferrer">lumitrace</a>#{footer_version_suffix}.</div>
233
+ <noscript><p class="noscript">Lumitrace HTML report requires JavaScript to render the source and trace view.</p></noscript>
234
+ <script id="lumitrace-payload" type="application/json">#{payload_json_for_script(payload)}</script>
235
+ <script>
236
+ #{html_renderer_js}
237
+ </script>
118
238
  </body>
119
239
  </html>
120
240
  HTML
121
241
  end
122
242
 
123
- def self.esc(s)
124
- s.to_s
125
- .gsub("&", "&amp;")
126
- .gsub("<", "&lt;")
127
- .gsub(">", "&gt;")
128
- .gsub('"', "&quot;")
129
- end
130
-
131
243
  def self.detect_collect_mode(events)
132
244
  arr = Array(events)
133
245
  return "history" if arr.any? { |e| e.key?(:sampled_values) || e.key?("sampled_values") }
@@ -277,6 +389,7 @@ module GenerateResultedHtml
277
389
  def self.comment_value_with_total_for_line(events)
278
390
  best = best_event_for_line(events)
279
391
  return nil unless best
392
+ return nil if best[:total].to_i <= 0
280
393
 
281
394
  sampled_last = best[:sampled_values]&.last
282
395
  v, t = last_value_to_pair(sampled_last)
@@ -380,9 +493,16 @@ module GenerateResultedHtml
380
493
 
381
494
  next if t <= s
382
495
  spans << { start_col: s, end_col: t }
383
- key_id = e[:key].join(":")
384
- buckets[e[:key]] = {
385
- key: e[:key],
496
+ event_key = e[:key] || [
497
+ e[:file],
498
+ e[:start_line].to_i,
499
+ e[:start_col].to_i,
500
+ e[:end_line].to_i,
501
+ e[:end_col].to_i
502
+ ]
503
+ key_id = event_key.join(":")
504
+ buckets[event_key] = {
505
+ key: event_key,
386
506
  key_id: key_id,
387
507
  start_col: s,
388
508
  end_col: t,
@@ -510,34 +630,6 @@ module GenerateResultedHtml
510
630
  ranges.any? { |(s, e)| line >= s && line <= e }
511
631
  end
512
632
 
513
- def self.add_missing_events(events, source, filename, ranges)
514
- expected = RecordInstrument.collect_locations_from_source(source, ranges || [])
515
- existing = {}
516
- events.each do |e|
517
- key = [e[:file], e[:start_line], e[:start_col], e[:end_line], e[:end_col]]
518
- existing[key] = true
519
- end
520
- expected.each do |loc|
521
- key = [filename, loc[:start_line], loc[:start_col], loc[:end_line], loc[:end_col]]
522
- next if existing[key]
523
- events << {
524
- key: key,
525
- file: key[0],
526
- start_line: key[1],
527
- start_col: key[2],
528
- end_line: key[3],
529
- end_col: key[4],
530
- kind: loc[:kind],
531
- name: loc[:name],
532
- sampled_values: [],
533
- types: {},
534
- total: 0
535
- }
536
- existing[key] = true
537
- end
538
- events
539
- end
540
-
541
633
  def self.line_stats(source, ranges, events, filename)
542
634
  expected_by_line = Hash.new(0)
543
635
  RecordInstrument.collect_locations_from_source(source, ranges || []).each do |loc|
@@ -561,293 +653,130 @@ module GenerateResultedHtml
561
653
  [expected_by_line, executed_by_line]
562
654
  end
563
655
 
564
- def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil)
656
+ def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil, logger: nil, command_text: nil)
565
657
  raw_events = JSON.parse(File.read(events_path))
566
- render_all_from_events(raw_events, root: root, ranges_by_file: ranges_by_file, collect_mode: collect_mode, max_samples: max_samples)
658
+ render_all_from_events(
659
+ raw_events,
660
+ root: root,
661
+ ranges_by_file: ranges_by_file,
662
+ collect_mode: collect_mode,
663
+ max_samples: max_samples,
664
+ logger: logger,
665
+ command_text: command_text
666
+ )
567
667
  end
568
668
 
569
- def self.render_all_from_events(events, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil)
570
- mode_info = resolve_mode_info(events, collect_mode: collect_mode, max_samples: max_samples)
571
- events = normalize_events(events)
572
- by_file = events.group_by { |e| e[:file] }
573
- ranges_by_file = normalize_ranges_by_file(ranges_by_file)
669
+ 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)
670
+ normalized = time_step("normalize_events", logger) { normalize_events(events) }
671
+ render_all_from_normalized_events(
672
+ normalized,
673
+ root: root,
674
+ ranges_by_file: ranges_by_file,
675
+ collect_mode: collect_mode,
676
+ max_samples: max_samples,
677
+ logger: logger,
678
+ command_text: command_text
679
+ )
680
+ end
681
+
682
+ 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)
683
+ total_start = monotonic_now
574
684
 
575
- target_paths = by_file.keys
685
+ mode_info = time_step("resolve_mode", logger) do
686
+ resolve_mode_info(events, collect_mode: collect_mode, max_samples: max_samples)
687
+ end
688
+ by_file = time_step("group_by_file", logger) { events.group_by { |e| e[:file] } }
689
+ ranges_by_file = time_step("normalize_ranges_by_file", logger) { normalize_ranges_by_file(ranges_by_file) }
576
690
 
577
- sections = target_paths.sort.map do |path|
691
+ files = []
692
+ by_file.keys.sort.each do |path|
578
693
  next unless File.exist?(path)
694
+ file_start = monotonic_now
695
+ read_start = logger ? monotonic_now : nil
579
696
  src = File.read(path)
697
+ read_ms = read_start ? (monotonic_now - read_start) * 1000.0 : nil
580
698
  if ranges_by_file
581
699
  next unless ranges_by_file.key?(path)
582
700
  ranges = ranges_by_file[path] || []
583
701
  else
584
702
  ranges = nil
585
703
  end
586
- file_events = add_missing_events((by_file[path] || []).dup, src, path, ranges)
587
- expected_by_line, executed_by_line = line_stats(src, ranges, file_events, path)
588
- html_lines = []
589
- prev_lineno = nil
590
- first_lineno = nil
591
- last_lineno = nil
592
- src.lines.each_with_index do |line, idx|
593
- lineno = idx + 1
594
- next if ranges && !line_in_ranges?(lineno, ranges)
595
- first_lineno ||= lineno
596
- if prev_lineno && lineno > prev_lineno + 1
597
- html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
598
- end
599
- line_text = line.chomp
600
- evs = aggregate_events_for_line(file_events, lineno, line_text.length)
601
- expected = expected_by_line[lineno]
602
- executed = executed_by_line[lineno]
603
- line_class = line_class_for(expected, executed)
604
- if expected > 0 && executed == 0
605
- evs.each { |e| e[:suppress_miss] = true }
606
- end
607
- if evs.empty?
608
- html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{esc(line_text)}</span>\n"
609
- else
610
- rendered = render_line_with_events(line_text, evs)
611
- html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{rendered}</span>\n"
612
- end
613
- prev_lineno = lineno
614
- last_lineno = lineno
615
- end
616
- if first_lineno && first_lineno > 1
617
- html_lines.unshift("<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n")
618
- end
619
- if last_lineno && last_lineno < src.lines.length
620
- html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
704
+ rel = path.start_with?(root) ? path.sub(root + File::SEPARATOR, "") : path
705
+ select_start = logger ? monotonic_now : nil
706
+ file_events = by_file[path] || []
707
+ select_ms = select_start ? (monotonic_now - select_start) * 1000.0 : nil
708
+ payload_file_start = logger ? monotonic_now : nil
709
+ files << build_html_payload_file(
710
+ path: path,
711
+ display_path: rel,
712
+ source: src,
713
+ ranges: ranges,
714
+ trace_events: file_events,
715
+ logger: logger
716
+ )
717
+ payload_file_ms = payload_file_start ? (monotonic_now - payload_file_start) * 1000.0 : nil
718
+ if logger
719
+ elapsed_ms = (monotonic_now - file_start) * 1000.0
720
+ 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))
621
721
  end
722
+ end
622
723
 
623
- rel = path.start_with?(root) ? path.sub(root + File::SEPARATOR, "") : path
624
- <<~HTML
625
- <h2 class="file">#{esc(rel)}</h2>
626
- <pre class="code"><code>
627
- #{html_lines.join("")}
628
- </code></pre>
629
- HTML
630
- end.compact.join("\n")
724
+ payload = time_step("build_payload", logger) { build_html_payload(mode_info: mode_info, files: files, command_text: command_text) }
725
+ html = time_step("render_payload_html", logger) { render_payload_html(payload) }
726
+ if logger
727
+ total_ms = (monotonic_now - total_start) * 1000.0
728
+ logger.call(format("html render: total files=%d events=%d html_bytes=%d %.1fms", files.length, events.length, html.bytesize, total_ms))
729
+ end
730
+ html
731
+ end
631
732
 
632
- <<~HTML
633
- <!doctype html>
634
- <html>
635
- <head>
636
- <meta charset="utf-8">
637
- <title>Recorded Result View</title>
638
- <style>
639
- body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
640
- .code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
641
- .line { display: inline-block; width: 100%; box-sizing: border-box; padding: 2px 8px; }
642
- .line:hover { background: #fff2c6; }
643
- .line.hit { background: #f0ffe7; }
644
- .line.miss { background: #ffecec; }
645
- .line.ellipsis { color: #999; }
646
- .ln { display: inline-block; width: 3em; color: #888; user-select: none; }
647
- .hint { color: #666; margin-bottom: 4px; }
648
- .mode { color: #444; margin-bottom: 8px; }
649
- .file { margin: 24px 0 8px; font-size: 16px; color: #333; }
650
- .expr { position: relative; display: inline-block; padding-bottom: 1px; }
651
- .expr.hit { }
652
- .expr.depth-1 { --hl: #7fbf7f; }
653
- .expr.depth-2 { --hl: #6fa8ff; }
654
- .expr.depth-3 { --hl: #ffb347; }
655
- .expr.depth-4 { --hl: #d78bff; }
656
- .expr.depth-5 { --hl: #ff6f91; }
657
- .expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
658
- .expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
659
- .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; }
660
- .marker.miss { color: #c07070; }
661
- .marker.arg { color: #2f6f8e; }
662
- .marker .tooltip {
663
- display: none;
664
- position: absolute;
665
- left: 0;
666
- top: 100%;
667
- margin-top: 4px;
668
- background: #2b2b2b;
669
- color: #fff;
670
- padding: 4px 6px;
671
- border-radius: 4px;
672
- font-size: 12px;
673
- white-space: pre;
674
- min-width: 16ch;
675
- max-width: 90vw;
676
- overflow-x: auto;
677
- overflow-y: hidden;
678
- z-index: 10;
679
- pointer-events: auto;
680
- }
681
- .marker:hover .tooltip,
682
- .marker:focus-within .tooltip,
683
- .marker .tooltip:hover { display: block; }
684
- </style>
685
- </head>
686
- <body>
687
- <div class="hint">Hover highlighted text to see recorded values.</div>
688
- <div class="mode">#{esc(mode_info[:text])}</div>
689
- #{sections}
690
- <script>
691
- (function() {
692
- document.querySelectorAll('.marker').forEach(marker => {
693
- marker.addEventListener('mouseenter', () => {
694
- document.querySelectorAll('.expr').forEach(e => e.classList.remove('active'));
695
- const key = marker.dataset.key;
696
- if (key) {
697
- document.querySelectorAll(`.expr[data-key="${key}"]`).forEach(e => e.classList.add('active'));
698
- } else {
699
- marker.closest('.expr')?.classList.add('active');
700
- }
701
- });
702
- marker.addEventListener('mouseleave', () => {
703
- const key = marker.dataset.key;
704
- if (key) {
705
- document.querySelectorAll(`.expr[data-key="${key}"]`).forEach(e => e.classList.remove('active'));
706
- } else {
707
- marker.closest('.expr')?.classList.remove('active');
708
- }
709
- });
710
- });
711
- })();
712
- </script>
713
- </body>
714
- </html>
715
- HTML
733
+ def self.render_source_from_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil, command_text: nil)
734
+ render_source_from_normalized_events(
735
+ source,
736
+ normalize_events(events),
737
+ filename: filename,
738
+ ranges: ranges,
739
+ collect_mode: collect_mode,
740
+ max_samples: max_samples,
741
+ command_text: command_text
742
+ )
716
743
  end
717
744
 
718
- def self.render_source_from_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil)
745
+ def self.render_source_from_normalized_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil, command_text: nil)
719
746
  mode_info = resolve_mode_info(events, collect_mode: collect_mode, max_samples: max_samples)
720
- events = normalize_events(events)
721
747
  ranges = normalize_ranges(ranges)
722
- target_events = add_missing_events(events.select { |e| e[:file] == filename }, source, filename, ranges)
723
- expected_by_line, executed_by_line = line_stats(source, ranges, target_events, filename)
748
+ target_events = events.select { |e| e[:file] == filename }
724
749
 
725
- html_lines = []
726
- prev_lineno = nil
727
- first_lineno = nil
728
- last_lineno = nil
729
- source.lines.each_with_index do |line, idx|
730
- lineno = idx + 1
731
- next if ranges && !line_in_ranges?(lineno, ranges)
732
- first_lineno ||= lineno
733
- if prev_lineno && lineno > prev_lineno + 1
734
- html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
735
- end
736
- line_text = line.chomp
737
- evs = aggregate_events_for_line(target_events, lineno, line_text.length)
738
- expected = expected_by_line[lineno]
739
- executed = executed_by_line[lineno]
740
- line_class = line_class_for(expected, executed)
741
- if expected > 0 && executed == 0
742
- evs.each { |e| e[:suppress_miss] = true }
743
- end
744
- if evs.empty?
745
- html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{esc(line_text)}</span>\n"
746
- else
747
- rendered = render_line_with_events(line_text, evs)
748
- html_lines << "<span class=\"line#{line_class}\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{rendered}</span>\n"
749
- end
750
- prev_lineno = lineno
751
- last_lineno = lineno
752
- end
753
- if first_lineno && first_lineno > 1
754
- html_lines.unshift("<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n")
755
- end
756
- if last_lineno && last_lineno < source.lines.length
757
- html_lines << "<span class=\"line ellipsis\" data-line=\"...\"><span class=\"ln\">...</span></span>\n"
758
- end
750
+ payload = build_html_payload(
751
+ mode_info: mode_info,
752
+ command_text: command_text,
753
+ files: [
754
+ build_html_payload_file(
755
+ path: filename,
756
+ display_path: filename,
757
+ source: source,
758
+ ranges: ranges,
759
+ trace_events: target_events
760
+ )
761
+ ]
762
+ )
759
763
 
760
- <<~HTML
761
- <!doctype html>
762
- <html>
763
- <head>
764
- <meta charset="utf-8">
765
- <title>Recorded Result View</title>
766
- <style>
767
- body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
768
- .code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
769
- .line { display: inline-block; width: 100%; box-sizing: border-box; padding: 2px 8px; }
770
- .line:hover { background: #fff2c6; }
771
- .line.hit { background: #f0ffe7; }
772
- .line.miss { background: #ffecec; }
773
- .line.ellipsis { color: #999; }
774
- .ln { display: inline-block; width: 3em; color: #888; user-select: none; }
775
- .hint { color: #666; margin-bottom: 4px; }
776
- .mode { color: #444; margin-bottom: 8px; }
777
- .file { margin: 24px 0 8px; font-size: 16px; color: #333; }
778
- .expr { position: relative; display: inline-block; padding-bottom: 1px; }
779
- .expr.hit { }
780
- .expr.depth-1 { --hl: #7fbf7f; }
781
- .expr.depth-2 { --hl: #6fa8ff; }
782
- .expr.depth-3 { --hl: #ffb347; }
783
- .expr.depth-4 { --hl: #d78bff; }
784
- .expr.depth-5 { --hl: #ff6f91; }
785
- .expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
786
- .expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
787
- .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; }
788
- .marker.miss { color: #c07070; }
789
- .marker.arg { color: #2f6f8e; }
790
- .marker .tooltip {
791
- display: none;
792
- position: absolute;
793
- left: 0;
794
- top: 100%;
795
- margin-top: 4px;
796
- background: #2b2b2b;
797
- color: #fff;
798
- padding: 4px 6px;
799
- border-radius: 4px;
800
- font-size: 12px;
801
- white-space: pre;
802
- min-width: 16ch;
803
- max-width: 90vw;
804
- overflow-x: auto;
805
- overflow-y: hidden;
806
- z-index: 10;
807
- pointer-events: auto;
808
- }
809
- .marker:hover .tooltip,
810
- .marker:focus-within .tooltip,
811
- .marker .tooltip:hover { display: block; }
812
- </style>
813
- </head>
814
- <body>
815
- <div class="hint">Hover highlighted text to see recorded values.</div>
816
- <div class="mode">#{esc(mode_info[:text])}</div>
817
- <h2 class="file">#{esc(filename)}</h2>
818
- <pre class="code"><code>
819
- #{html_lines.join("")}
820
- </code></pre>
821
- <script>
822
- (function() {
823
- document.querySelectorAll('.marker').forEach(marker => {
824
- marker.addEventListener('mouseenter', () => {
825
- document.querySelectorAll('.expr').forEach(e => e.classList.remove('active'));
826
- const key = marker.dataset.key;
827
- if (key) {
828
- document.querySelectorAll(`.expr[data-key="${key}"]`).forEach(e => e.classList.add('active'));
829
- } else {
830
- marker.closest('.expr')?.classList.add('active');
831
- }
832
- });
833
- marker.addEventListener('mouseleave', () => {
834
- const key = marker.dataset.key;
835
- if (key) {
836
- document.querySelectorAll(`.expr[data-key="${key}"]`).forEach(e => e.classList.remove('active'));
837
- } else {
838
- marker.closest('.expr')?.classList.remove('active');
839
- }
840
- });
841
- });
842
- })();
843
- </script>
844
- </body>
845
- </html>
846
- HTML
764
+ render_payload_html(payload)
847
765
  end
848
766
 
849
767
  def self.render_text_from_events(source, events, filename: "script.rb", ranges: nil, with_header: true, header_label: nil, tty: nil)
850
- events = normalize_events(events)
768
+ render_text_from_normalized_events(
769
+ source,
770
+ normalize_events(events),
771
+ filename: filename,
772
+ ranges: ranges,
773
+ with_header: with_header,
774
+ header_label: header_label,
775
+ tty: tty
776
+ )
777
+ end
778
+
779
+ def self.render_text_from_normalized_events(source, events, filename: "script.rb", ranges: nil, with_header: true, header_label: nil, tty: nil)
851
780
  ranges = normalize_ranges(ranges)
852
781
  target_events = events.select { |e| e[:file] == filename }
853
782
  term_width = tty ? terminal_width : nil
@@ -869,7 +798,7 @@ module GenerateResultedHtml
869
798
  ]
870
799
  next if seen[key]
871
800
  seen[key] = true
872
- executed_by_line[line] += 1 if line
801
+ executed_by_line[line] += 1 if line && (e[:total] || e["total"]).to_i > 0
873
802
  end
874
803
 
875
804
  out = +""
@@ -970,7 +899,10 @@ module GenerateResultedHtml
970
899
  end
971
900
 
972
901
  def self.render_text_all_from_events(events, root: Dir.pwd, ranges_by_file: nil, tty: nil)
973
- events = normalize_events(events)
902
+ render_text_all_from_normalized_events(normalize_events(events), root: root, ranges_by_file: ranges_by_file, tty: tty)
903
+ end
904
+
905
+ def self.render_text_all_from_normalized_events(events, root: Dir.pwd, ranges_by_file: nil, tty: nil)
974
906
  by_file = events.group_by { |e| e[:file] }
975
907
  ranges_by_file = normalize_ranges_by_file(ranges_by_file)
976
908
 
@@ -984,7 +916,7 @@ module GenerateResultedHtml
984
916
  ranges = nil
985
917
  end
986
918
  rel = path.start_with?(root) ? path.sub(root + File::SEPARATOR, "") : path
987
- render_text_from_events(src, events, filename: path, ranges: ranges, with_header: true, header_label: rel, tty: tty)
919
+ render_text_from_normalized_events(src, events, filename: path, ranges: ranges, with_header: true, header_label: rel, tty: tty)
988
920
  end.compact
989
921
 
990
922
  header = "\n=== Lumitrace Results (text) ===\n\n"