lumitrace 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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}"
@@ -11,51 +25,199 @@ module GenerateResultedHtml
11
25
  abort "missing #{source_path}"
12
26
  end
13
27
 
14
- raw_events = JSON.parse(File.read(events_path))
28
+ raw = JSON.parse(File.read(events_path))
29
+ raw_events = raw.is_a?(Hash) && raw.key?("events") ? raw["events"] : raw
30
+ src = File.read(source_path)
15
31
  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)
32
+ normalized_ranges = normalize_ranges(ranges)
33
+ events = normalize_events(raw_events).select { |e| e[:file] == source_path }
34
+
35
+ payload = build_html_payload(
36
+ mode_info: mode_info,
37
+ files: [
38
+ build_html_payload_file(
39
+ path: source_path,
40
+ display_path: File.basename(source_path),
41
+ source: src,
42
+ ranges: normalized_ranges,
43
+ trace_events: events
44
+ )
45
+ ]
46
+ )
18
47
 
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)
48
+ render_payload_html(payload)
49
+ end
23
50
 
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")
51
+ def self.esc(s)
52
+ s.to_s
53
+ .gsub("&", "&amp;")
54
+ .gsub("<", "&lt;")
55
+ .gsub(">", "&gt;")
56
+ .gsub('"', "&quot;")
57
+ end
58
+
59
+ def self.build_html_payload(mode_info:, files:, command_text: nil)
60
+ meta = {
61
+ mode: mode_info[:mode],
62
+ mode_text: mode_info[:text],
63
+ max_samples: mode_info[:max_samples]
64
+ }
65
+ meta[:command] = command_text if command_text && !command_text.to_s.empty?
66
+ {
67
+ version: 1,
68
+ meta: meta,
69
+ files: files
70
+ }
71
+ end
72
+
73
+ def self.build_html_payload_file(path:, display_path:, source:, ranges:, trace_events:, logger: nil)
74
+ sort_start = logger ? monotonic_now : nil
75
+ sorted_events = Array(trace_events).sort_by do |e|
76
+ [e[:start_line].to_i, e[:start_col].to_i, e[:end_line].to_i, e[:end_col].to_i]
54
77
  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"
78
+ sort_ms = sort_start ? (monotonic_now - sort_start) * 1000.0 : nil
79
+
80
+ map_start = logger ? monotonic_now : nil
81
+ trace_payload = sorted_events.map { |e| event_to_html_trace_payload(e) }
82
+ map_ms = map_start ? (monotonic_now - map_start) * 1000.0 : nil
83
+ if logger
84
+ logger.call(format("html render: payload_file %s sort=%.1fms map=%.1fms events=%d", display_path, sort_ms, map_ms, sorted_events.length))
57
85
  end
58
86
 
87
+ {
88
+ path: path,
89
+ display_path: display_path,
90
+ source: source,
91
+ ranges: ranges,
92
+ trace: trace_payload
93
+ }
94
+ end
95
+
96
+ def self.event_to_html_trace_payload(e)
97
+ sampled_values = e[:sampled_values]
98
+ if (sampled_values.nil? || sampled_values.empty?) && e[:last_value]
99
+ sampled_values = [e[:last_value]]
100
+ end
101
+ {
102
+ location: [
103
+ e[:start_line].to_i,
104
+ e[:start_col].to_i,
105
+ e[:end_line].to_i,
106
+ e[:end_col].to_i
107
+ ],
108
+ kind: (e[:kind] || "expr").to_s,
109
+ name: e[:name],
110
+ sampled_values: sampled_values || [],
111
+ types: sorted_type_counts(e[:types]),
112
+ total: e[:total].to_i
113
+ }
114
+ end
115
+
116
+ def self.payload_json_for_script(payload)
117
+ JSON.generate(payload)
118
+ .gsub("</", "<\\/")
119
+ .gsub("\u2028", "\\u2028")
120
+ .gsub("\u2029", "\\u2029")
121
+ end
122
+
123
+ def self.html_renderer_js
124
+ @html_renderer_js ||= File.read(RENDERER_JS_PATH)
125
+ end
126
+
127
+ def self.html_report_styles
128
+ <<~CSS
129
+ body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
130
+ .report-layout { display: grid; grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); gap: 16px; align-items: start; }
131
+ .report-layout.single-file { grid-template-columns: minmax(0, 1fr); }
132
+ .report-sidebar { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 12px; position: sticky; top: 16px; max-height: calc(100vh - 48px); overflow: hidden; }
133
+ .report-layout.single-file .report-sidebar { display: none; }
134
+ .tree-title { color: #444; font-size: 12px; margin-bottom: 8px; }
135
+ .tree-scroll { overflow: auto; max-height: calc(100vh - 96px); }
136
+ .tree-list { list-style: none; margin: 0; padding: 0; }
137
+ .tree-list[data-level]:not([data-level="0"]) { margin-left: 14px; border-left: 1px dashed #e5dfd0; padding-left: 8px; }
138
+ .tree-dir, .tree-file { margin: 2px 0; }
139
+ .tree-folder { }
140
+ .tree-folder-label { cursor: pointer; color: #4d473f; user-select: none; }
141
+ .tree-folder-label::marker { color: #999; }
142
+ .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; }
143
+ .tree-file-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
144
+ .tree-file-meta { color: #6f6a62; font-size: 12px; white-space: nowrap; }
145
+ .tree-file-btn:hover { background: #fff2c6; }
146
+ .tree-file-btn.active { background: #f0ffe7; color: #1b5e3d; }
147
+ .tree-file-btn.active .tree-file-meta { color: #1b5e3d; }
148
+ .report-main { min-width: 0; }
149
+ .report-main-head { display: flex; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 8px; }
150
+ .current-file { color: #333; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
151
+ .report-viewer { min-width: 0; }
152
+ .file-section { min-width: 0; }
153
+ .code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
154
+ .line { display: block; box-sizing: border-box; padding: 2px 8px; }
155
+ .line:hover { background: #fff2c6; }
156
+ .line.hit { background: #f0ffe7; }
157
+ .line.miss { background: #ffecec; }
158
+ .line.line-target { box-shadow: inset 3px 0 #2f6f8e; background: #e9f4fb; }
159
+ .line.ellipsis { color: #999; }
160
+ .ln { display: inline-block; width: 3em; color: #888; user-select: none; }
161
+ .ln-link { color: inherit; text-decoration: none; }
162
+ .ln-link:hover { text-decoration: underline; color: #2f6f8e; }
163
+ .hint { color: #666; margin-bottom: 4px; }
164
+ .command { color: #555; margin-bottom: 4px; font-size: 12px; overflow-wrap: anywhere; }
165
+ .mode { color: #444; margin-bottom: 8px; }
166
+ .report-footer { margin-top: 16px; color: #666; font-size: 12px; }
167
+ .report-footer a { color: #2f6f8e; text-decoration: none; }
168
+ .report-footer a:hover { text-decoration: underline; }
169
+ .file { margin: 24px 0 8px; font-size: 16px; color: #333; }
170
+ .expr { position: relative; display: inline-block; padding-bottom: 1px; }
171
+ .expr.hit { }
172
+ .expr.depth-1 { --hl: #7fbf7f; }
173
+ .expr.depth-2 { --hl: #6fa8ff; }
174
+ .expr.depth-3 { --hl: #ffb347; }
175
+ .expr.depth-4 { --hl: #d78bff; }
176
+ .expr.depth-5 { --hl: #ff6f91; }
177
+ .expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
178
+ .expr.miss { background: rgba(255, 120, 120, 0.18); box-shadow: inset 0 -2px rgba(200, 120, 120, 0.6); }
179
+ .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; }
180
+ .marker.miss { color: #c07070; }
181
+ .marker.arg { color: #2f6f8e; }
182
+ .marker .tooltip {
183
+ display: none;
184
+ position: absolute;
185
+ left: 0;
186
+ bottom: 100%;
187
+ margin-bottom: 6px;
188
+ background: #2b2b2b;
189
+ color: #fff;
190
+ padding: 4px 6px;
191
+ border-radius: 4px;
192
+ font-size: 12px;
193
+ white-space: pre;
194
+ min-width: 16ch;
195
+ max-width: 90vw;
196
+ overflow-x: auto;
197
+ overflow-y: hidden;
198
+ z-index: 10;
199
+ pointer-events: auto;
200
+ }
201
+ .marker:hover .tooltip,
202
+ .marker:focus-within .tooltip,
203
+ .marker .tooltip:hover { display: block; }
204
+ .noscript { color: #666; }
205
+ @media (max-width: 900px) {
206
+ body { padding: 16px; }
207
+ .report-layout { grid-template-columns: 1fr; }
208
+ .report-sidebar { position: static; max-height: none; }
209
+ .tree-scroll { max-height: 220px; }
210
+ .report-main-head { flex-direction: column; align-items: flex-start; }
211
+ }
212
+ CSS
213
+ end
214
+
215
+ def self.footer_version_suffix
216
+ return "" unless defined?(Lumitrace::VERSION) && Lumitrace::VERSION
217
+ " v#{Lumitrace::VERSION}"
218
+ end
219
+
220
+ def self.render_payload_html(payload)
59
221
  <<~HTML
60
222
  <!doctype html>
61
223
  <html>
@@ -63,71 +225,22 @@ module GenerateResultedHtml
63
225
  <meta charset="utf-8">
64
226
  <title>Recorded Result View</title>
65
227
  <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; }
228
+ #{html_report_styles}
110
229
  </style>
111
230
  </head>
112
231
  <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>
232
+ <div id="lumitrace-app"></div>
233
+ <div class="report-footer">Generated by <a href="https://ko1.github.io/lumitrace/" target="_blank" rel="noopener noreferrer">lumitrace</a>#{footer_version_suffix}.</div>
234
+ <noscript><p class="noscript">Lumitrace HTML report requires JavaScript to render the source and trace view.</p></noscript>
235
+ <script id="lumitrace-payload" type="application/json">#{payload_json_for_script(payload)}</script>
236
+ <script>
237
+ #{html_renderer_js}
238
+ </script>
118
239
  </body>
119
240
  </html>
120
241
  HTML
121
242
  end
122
243
 
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
244
  def self.detect_collect_mode(events)
132
245
  arr = Array(events)
133
246
  return "history" if arr.any? { |e| e.key?(:sampled_values) || e.key?("sampled_values") }
@@ -277,6 +390,7 @@ module GenerateResultedHtml
277
390
  def self.comment_value_with_total_for_line(events)
278
391
  best = best_event_for_line(events)
279
392
  return nil unless best
393
+ return nil if best[:total].to_i <= 0
280
394
 
281
395
  sampled_last = best[:sampled_values]&.last
282
396
  v, t = last_value_to_pair(sampled_last)
@@ -380,9 +494,16 @@ module GenerateResultedHtml
380
494
 
381
495
  next if t <= s
382
496
  spans << { start_col: s, end_col: t }
383
- key_id = e[:key].join(":")
384
- buckets[e[:key]] = {
385
- key: e[:key],
497
+ event_key = e[:key] || [
498
+ e[:file],
499
+ e[:start_line].to_i,
500
+ e[:start_col].to_i,
501
+ e[:end_line].to_i,
502
+ e[:end_col].to_i
503
+ ]
504
+ key_id = event_key.join(":")
505
+ buckets[event_key] = {
506
+ key: event_key,
386
507
  key_id: key_id,
387
508
  start_col: s,
388
509
  end_col: t,
@@ -510,34 +631,6 @@ module GenerateResultedHtml
510
631
  ranges.any? { |(s, e)| line >= s && line <= e }
511
632
  end
512
633
 
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
634
  def self.line_stats(source, ranges, events, filename)
542
635
  expected_by_line = Hash.new(0)
543
636
  RecordInstrument.collect_locations_from_source(source, ranges || []).each do |loc|
@@ -561,293 +654,131 @@ module GenerateResultedHtml
561
654
  [expected_by_line, executed_by_line]
562
655
  end
563
656
 
564
- def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil)
565
- 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)
657
+ def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil, collect_mode: nil, max_samples: nil, logger: nil, command_text: nil)
658
+ raw = JSON.parse(File.read(events_path))
659
+ raw_events = raw.is_a?(Hash) && raw.key?("events") ? raw["events"] : raw
660
+ render_all_from_events(
661
+ raw_events,
662
+ root: root,
663
+ ranges_by_file: ranges_by_file,
664
+ collect_mode: collect_mode,
665
+ max_samples: max_samples,
666
+ logger: logger,
667
+ command_text: command_text
668
+ )
567
669
  end
568
670
 
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)
671
+ 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)
672
+ normalized = time_step("normalize_events", logger) { normalize_events(events) }
673
+ render_all_from_normalized_events(
674
+ normalized,
675
+ root: root,
676
+ ranges_by_file: ranges_by_file,
677
+ collect_mode: collect_mode,
678
+ max_samples: max_samples,
679
+ logger: logger,
680
+ command_text: command_text
681
+ )
682
+ end
683
+
684
+ 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)
685
+ total_start = monotonic_now
574
686
 
575
- target_paths = by_file.keys
687
+ mode_info = time_step("resolve_mode", logger) do
688
+ resolve_mode_info(events, collect_mode: collect_mode, max_samples: max_samples)
689
+ end
690
+ by_file = time_step("group_by_file", logger) { events.group_by { |e| e[:file] } }
691
+ ranges_by_file = time_step("normalize_ranges_by_file", logger) { normalize_ranges_by_file(ranges_by_file) }
576
692
 
577
- sections = target_paths.sort.map do |path|
693
+ files = []
694
+ by_file.keys.sort.each do |path|
578
695
  next unless File.exist?(path)
696
+ file_start = monotonic_now
697
+ read_start = logger ? monotonic_now : nil
579
698
  src = File.read(path)
699
+ read_ms = read_start ? (monotonic_now - read_start) * 1000.0 : nil
580
700
  if ranges_by_file
581
701
  next unless ranges_by_file.key?(path)
582
702
  ranges = ranges_by_file[path] || []
583
703
  else
584
704
  ranges = nil
585
705
  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"
706
+ rel = path.start_with?(root) ? path.sub(root + File::SEPARATOR, "") : path
707
+ select_start = logger ? monotonic_now : nil
708
+ file_events = by_file[path] || []
709
+ select_ms = select_start ? (monotonic_now - select_start) * 1000.0 : nil
710
+ payload_file_start = logger ? monotonic_now : nil
711
+ files << build_html_payload_file(
712
+ path: path,
713
+ display_path: rel,
714
+ source: src,
715
+ ranges: ranges,
716
+ trace_events: file_events,
717
+ logger: logger
718
+ )
719
+ payload_file_ms = payload_file_start ? (monotonic_now - payload_file_start) * 1000.0 : nil
720
+ if logger
721
+ elapsed_ms = (monotonic_now - file_start) * 1000.0
722
+ 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
723
  end
724
+ end
622
725
 
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")
726
+ payload = time_step("build_payload", logger) { build_html_payload(mode_info: mode_info, files: files, command_text: command_text) }
727
+ html = time_step("render_payload_html", logger) { render_payload_html(payload) }
728
+ if logger
729
+ total_ms = (monotonic_now - total_start) * 1000.0
730
+ logger.call(format("html render: total files=%d events=%d html_bytes=%d %.1fms", files.length, events.length, html.bytesize, total_ms))
731
+ end
732
+ html
733
+ end
631
734
 
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
735
+ def self.render_source_from_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil, command_text: nil)
736
+ render_source_from_normalized_events(
737
+ source,
738
+ normalize_events(events),
739
+ filename: filename,
740
+ ranges: ranges,
741
+ collect_mode: collect_mode,
742
+ max_samples: max_samples,
743
+ command_text: command_text
744
+ )
716
745
  end
717
746
 
718
- def self.render_source_from_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil)
747
+ def self.render_source_from_normalized_events(source, events, filename: "script.rb", ranges: nil, collect_mode: nil, max_samples: nil, command_text: nil)
719
748
  mode_info = resolve_mode_info(events, collect_mode: collect_mode, max_samples: max_samples)
720
- events = normalize_events(events)
721
749
  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)
750
+ target_events = events.select { |e| e[:file] == filename }
724
751
 
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
752
+ payload = build_html_payload(
753
+ mode_info: mode_info,
754
+ command_text: command_text,
755
+ files: [
756
+ build_html_payload_file(
757
+ path: filename,
758
+ display_path: filename,
759
+ source: source,
760
+ ranges: ranges,
761
+ trace_events: target_events
762
+ )
763
+ ]
764
+ )
759
765
 
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
766
+ render_payload_html(payload)
847
767
  end
848
768
 
849
769
  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)
770
+ render_text_from_normalized_events(
771
+ source,
772
+ normalize_events(events),
773
+ filename: filename,
774
+ ranges: ranges,
775
+ with_header: with_header,
776
+ header_label: header_label,
777
+ tty: tty
778
+ )
779
+ end
780
+
781
+ def self.render_text_from_normalized_events(source, events, filename: "script.rb", ranges: nil, with_header: true, header_label: nil, tty: nil)
851
782
  ranges = normalize_ranges(ranges)
852
783
  target_events = events.select { |e| e[:file] == filename }
853
784
  term_width = tty ? terminal_width : nil
@@ -869,7 +800,7 @@ module GenerateResultedHtml
869
800
  ]
870
801
  next if seen[key]
871
802
  seen[key] = true
872
- executed_by_line[line] += 1 if line
803
+ executed_by_line[line] += 1 if line && (e[:total] || e["total"]).to_i > 0
873
804
  end
874
805
 
875
806
  out = +""
@@ -970,7 +901,10 @@ module GenerateResultedHtml
970
901
  end
971
902
 
972
903
  def self.render_text_all_from_events(events, root: Dir.pwd, ranges_by_file: nil, tty: nil)
973
- events = normalize_events(events)
904
+ render_text_all_from_normalized_events(normalize_events(events), root: root, ranges_by_file: ranges_by_file, tty: tty)
905
+ end
906
+
907
+ def self.render_text_all_from_normalized_events(events, root: Dir.pwd, ranges_by_file: nil, tty: nil)
974
908
  by_file = events.group_by { |e| e[:file] }
975
909
  ranges_by_file = normalize_ranges_by_file(ranges_by_file)
976
910
 
@@ -984,7 +918,7 @@ module GenerateResultedHtml
984
918
  ranges = nil
985
919
  end
986
920
  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)
921
+ render_text_from_normalized_events(src, events, filename: path, ranges: ranges, with_header: true, header_label: rel, tty: tty)
988
922
  end.compact
989
923
 
990
924
  header = "\n=== Lumitrace Results (text) ===\n\n"