lumitrace 0.1.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.
- checksums.yaml +7 -0
- data/README.md +92 -0
- data/Rakefile +12 -0
- data/exe/lumitrace +88 -0
- data/lib/lumitrace/enable.rb +5 -0
- data/lib/lumitrace/enable_git_diff.rb +74 -0
- data/lib/lumitrace/generate_resulted_html.rb +397 -0
- data/lib/lumitrace/record_instrument.rb +234 -0
- data/lib/lumitrace/record_require.rb +110 -0
- data/lib/lumitrace/version.rb +5 -0
- data/lib/lumitrace.rb +43 -0
- data/sample_project/.github/workflows/lumitrace-sample.yml +38 -0
- data/sample_project/Gemfile +5 -0
- data/sample_project/Rakefile +11 -0
- data/sample_project/test/test_sample_test.rb +10 -0
- data/sample_project/test.rb +9 -0
- data/sig/lumitrace.rbs +4 -0
- data/test/test_lumitrace.rb +89 -0
- metadata +59 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2ba7add22897e99449be04384c54d69bc475240840e2d229f73b90a5f289c0a0
|
|
4
|
+
data.tar.gz: 259751a540b06b2f790bef892f14191b1daaa8341ec5a484b8e91affe46120fa
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cf8fc986e2d8ff10480bbbbb8ce57eac21cd9d72ba94a83604ffdba7e4cdccd8b6cb9a8e0e52b6413f5bf098a4d8d4d90e60c8cd70038d52bb0826d83b26c56a
|
|
7
|
+
data.tar.gz: e3752f0b9f8249345443352898c69a138e4cdb35f5ff3257c5a52b7bd9665b0ac48e6c0aca16575a6577b67838d47d709c453238ce35fc5ba6d8506c1a0420b2
|
data/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Lumitrace
|
|
2
|
+
|
|
3
|
+
Lumitrace instruments Ruby source code at load time, records expression results, and renders an HTML view that overlays recorded values on your code. It is designed for quick, local “what happened here?” inspection during test runs or scripts.
|
|
4
|
+
|
|
5
|
+
## How It Works
|
|
6
|
+
|
|
7
|
+
Lumitrace hooks `RubyVM::InstructionSequence.translate` (when available) to rewrite files at require-time. It records expression results and renders an HTML view that shows them inline. Only the last N values per expression are kept to avoid huge output.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
### CLI
|
|
12
|
+
|
|
13
|
+
Run a script and emit HTML (default output: `lumitrace_recorded.html`):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
ruby exe/lumitrace path/to/entry.rb
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Limit the number of recorded values per expression (defaults to 3):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
LUMITRACE_VALUES_MAX=5 ruby exe/lumitrace path/to/entry.rb
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Write JSON output explicitly:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
ruby exe/lumitrace path/to/entry.rb --json
|
|
29
|
+
ruby exe/lumitrace path/to/entry.rb --json out/lumitrace_recorded.json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Restrict to specific line ranges:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
ruby exe/lumitrace path/to/entry.rb --range path/to/entry.rb:10-20,30-35
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Library
|
|
39
|
+
|
|
40
|
+
Enable instrumentation and HTML output at exit:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
require "lumitrace"
|
|
44
|
+
Lumitrace.enable!
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Enable only for diff ranges (current file):
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
require "lumitrace/enable_git_diff"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If you want to enable via a single require:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
require "lumitrace/enable"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Output
|
|
60
|
+
|
|
61
|
+
- HTML: `lumitrace_recorded.html` by default, override with `LUMITRACE_HTML_OUT`.
|
|
62
|
+
- JSON: written only when `--json` (CLI) or `LUMITRACE_JSON_OUT` (library) is provided. Default filename is `lumitrace_recorded.json`.
|
|
63
|
+
|
|
64
|
+
## Environment Variables
|
|
65
|
+
|
|
66
|
+
- `LUMITRACE_VALUES_MAX`: default max values per expression (default 3 if unset).
|
|
67
|
+
- `LUMITRACE_ROOT`: root directory used to decide which files are instrumented.
|
|
68
|
+
- `LUMITRACE_HTML_OUT`: override HTML output path.
|
|
69
|
+
- `LUMITRACE_JSON_OUT`: if set, writes JSON to this path at exit.
|
|
70
|
+
- `LUMITRACE_GIT_DIFF=working|staged|base:REV|range:SPEC`: diff source for `enable_git_diff`.
|
|
71
|
+
- `LUMITRACE_GIT_DIFF_CONTEXT=N`: expand diff hunks by +/-N lines (default 3).
|
|
72
|
+
- `LUMITRACE_GIT_CMD`: git executable override (default `git`).
|
|
73
|
+
|
|
74
|
+
## Notes And Limitations
|
|
75
|
+
|
|
76
|
+
- Requires `RubyVM::InstructionSequence.translate` support.
|
|
77
|
+
- Very large projects or hot loops can still generate large HTML; use `LUMITRACE_VALUES_MAX`.
|
|
78
|
+
- Instrumentation changes evaluation order for debugging, not for production.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
Install dependencies:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
bundle install
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Run the CLI locally:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
ruby exe/lumitrace path/to/entry.rb
|
|
92
|
+
```
|
data/Rakefile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new do |t|
|
|
7
|
+
t.libs << "test"
|
|
8
|
+
t.test_files = FileList["test/**/*_test.rb", "test/**/test_*.rb"]
|
|
9
|
+
t.verbose = false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
task default: :test
|
data/exe/lumitrace
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require_relative "../lib/lumitrace"
|
|
7
|
+
|
|
8
|
+
html_out = nil
|
|
9
|
+
max_values = nil
|
|
10
|
+
range_specs = []
|
|
11
|
+
json_out = nil
|
|
12
|
+
|
|
13
|
+
emit_html = true
|
|
14
|
+
|
|
15
|
+
parser = OptionParser.new do |opts|
|
|
16
|
+
opts.banner = "usage: lumitrace FILE [options]"
|
|
17
|
+
opts.on("--html PATH", "Write HTML output to PATH") do |v|
|
|
18
|
+
emit_html = true
|
|
19
|
+
html_out = v
|
|
20
|
+
end
|
|
21
|
+
opts.on("--json [PATH]", "Write JSON output (default: lumitrace_recorded.json)") do |v|
|
|
22
|
+
json_out = v || ""
|
|
23
|
+
end
|
|
24
|
+
opts.on("--max N", Integer, "Max values per expression") do |v|
|
|
25
|
+
max_values = v
|
|
26
|
+
end
|
|
27
|
+
opts.on("--range SPEC", "Restrict instrumentation per file: FILE:1-5,10-12") do |v|
|
|
28
|
+
range_specs << v
|
|
29
|
+
end
|
|
30
|
+
opts.on("-h", "--help", "Show help") do
|
|
31
|
+
puts opts
|
|
32
|
+
exit 0
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
args = parser.parse(ARGV)
|
|
37
|
+
path = args.shift or abort(parser.to_s)
|
|
38
|
+
|
|
39
|
+
env_max = ENV["LUMITRACE_VALUES_MAX"]
|
|
40
|
+
max_values = env_max if max_values.nil? && env_max && !env_max.empty?
|
|
41
|
+
|
|
42
|
+
ranges_by_file = nil
|
|
43
|
+
if range_specs.any?
|
|
44
|
+
ranges_by_file = Hash.new { |h, k| h[k] = [] }
|
|
45
|
+
range_specs.each do |spec|
|
|
46
|
+
file_part, range_part = spec.split(":", 2)
|
|
47
|
+
if file_part.nil? || file_part.strip.empty?
|
|
48
|
+
abort "invalid --range (expected FILE or FILE:1-5,10-12): #{spec}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
file = File.expand_path(file_part)
|
|
52
|
+
if range_part.nil? || range_part.strip.empty?
|
|
53
|
+
ranges_by_file[file] = []
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
range_part.split(",").each do |seg|
|
|
58
|
+
seg = seg.strip
|
|
59
|
+
if seg =~ /\A(\d+)-(\d+)\z/
|
|
60
|
+
ranges_by_file[file] << ($1.to_i..$2.to_i)
|
|
61
|
+
else
|
|
62
|
+
abort "invalid --range segment (expected N-M): #{seg}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Lumitrace.enable!(max_values: max_values, ranges_by_file: ranges_by_file)
|
|
69
|
+
load File.expand_path(path)
|
|
70
|
+
events = Lumitrace::RecordInstrument.events
|
|
71
|
+
|
|
72
|
+
if json_out
|
|
73
|
+
json_path = json_out.empty? ? "lumitrace_recorded.json" : json_out
|
|
74
|
+
events_path = File.expand_path(json_path, Dir.pwd)
|
|
75
|
+
Lumitrace.dump!(events_path)
|
|
76
|
+
puts "recorded: #{events_path}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if emit_html
|
|
80
|
+
html = Lumitrace::GenerateResultedHtml.render_all_from_events(
|
|
81
|
+
events,
|
|
82
|
+
root: File.dirname(File.expand_path(path)),
|
|
83
|
+
ranges_by_file: ranges_by_file
|
|
84
|
+
)
|
|
85
|
+
out_path = html_out || File.join(File.dirname(File.expand_path(path)), "lumitrace_recorded.html")
|
|
86
|
+
File.write(out_path, html)
|
|
87
|
+
puts "html: #{out_path}"
|
|
88
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "../lumitrace"
|
|
5
|
+
|
|
6
|
+
def lumitrace_parse_git_diff_ranges(diff_text, root)
|
|
7
|
+
ranges_by_file = Hash.new { |h, k| h[k] = [] }
|
|
8
|
+
current_file = nil
|
|
9
|
+
|
|
10
|
+
diff_text.each_line do |line|
|
|
11
|
+
if line.start_with?("+++ ")
|
|
12
|
+
path = line.sub("+++ ", "").strip
|
|
13
|
+
if path == "/dev/null"
|
|
14
|
+
current_file = nil
|
|
15
|
+
else
|
|
16
|
+
path = path.sub(%r{\A[ab]/}, "")
|
|
17
|
+
current_file = File.expand_path(path, root)
|
|
18
|
+
end
|
|
19
|
+
elsif line.start_with?("@@")
|
|
20
|
+
next unless current_file
|
|
21
|
+
if line =~ /\+(\d+)(?:,(\d+))?\s@@/
|
|
22
|
+
start_line = Regexp.last_match(1).to_i
|
|
23
|
+
count = Regexp.last_match(2) ? Regexp.last_match(2).to_i : 1
|
|
24
|
+
next if count == 0
|
|
25
|
+
end_line = start_line + count - 1
|
|
26
|
+
context = ENV.fetch("LUMITRACE_GIT_DIFF_CONTEXT", "3").to_i
|
|
27
|
+
context = 0 if context < 0
|
|
28
|
+
start_line = [start_line - context, 1].max
|
|
29
|
+
end_line += context
|
|
30
|
+
ranges_by_file[current_file] << (start_line..end_line)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
ranges_by_file
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def lumitrace_diff_args
|
|
39
|
+
mode = ENV.fetch("LUMITRACE_GIT_DIFF", "working")
|
|
40
|
+
case mode
|
|
41
|
+
when "working"
|
|
42
|
+
[]
|
|
43
|
+
when "staged"
|
|
44
|
+
["--cached"]
|
|
45
|
+
when /\Abase:(.+)\z/
|
|
46
|
+
[Regexp.last_match(1)]
|
|
47
|
+
when /\Arange:(.+)\z/
|
|
48
|
+
[Regexp.last_match(1)]
|
|
49
|
+
else
|
|
50
|
+
abort "invalid LUMITRACE_GIT_DIFF (working|staged|base:REV|range:SPEC): #{mode}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def lumitrace_git_root(dir, git_cmd)
|
|
55
|
+
out, status = Open3.capture2(git_cmd, "-C", dir, "rev-parse", "--show-toplevel")
|
|
56
|
+
return dir unless status.success?
|
|
57
|
+
out.strip
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def lumitrace_diff_ranges
|
|
61
|
+
target = File.expand_path($PROGRAM_NAME)
|
|
62
|
+
base_dir = File.dirname(target)
|
|
63
|
+
git_cmd = ENV.fetch("LUMITRACE_GIT_CMD", "git")
|
|
64
|
+
root = lumitrace_git_root(base_dir, git_cmd)
|
|
65
|
+
args = [git_cmd, "-C", base_dir, "diff", "--unified=0", "--no-color"] + lumitrace_diff_args
|
|
66
|
+
args += ["--", target] if File.file?(target)
|
|
67
|
+
stdout, status = Open3.capture2(*args)
|
|
68
|
+
return nil unless status.success?
|
|
69
|
+
ranges_by_file = lumitrace_parse_git_diff_ranges(stdout, root)
|
|
70
|
+
ranges_by_file.empty? ? nil : ranges_by_file
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
ranges_by_file = lumitrace_diff_ranges
|
|
74
|
+
Lumitrace.enable!(ranges_by_file: ranges_by_file, at_exit: true) if ranges_by_file
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Lumitrace
|
|
4
|
+
module GenerateResultedHtml
|
|
5
|
+
def self.render(source_path, events_path, ranges: nil)
|
|
6
|
+
unless File.exist?(events_path)
|
|
7
|
+
abort "missing #{events_path}"
|
|
8
|
+
end
|
|
9
|
+
unless File.exist?(source_path)
|
|
10
|
+
abort "missing #{source_path}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
raw_events = JSON.parse(File.read(events_path))
|
|
14
|
+
events = normalize_events(raw_events)
|
|
15
|
+
|
|
16
|
+
src_lines = File.read(source_path).lines
|
|
17
|
+
ranges = normalize_ranges(ranges)
|
|
18
|
+
|
|
19
|
+
html_lines = src_lines.each_with_index.map do |line, idx|
|
|
20
|
+
lineno = idx + 1
|
|
21
|
+
next if ranges && !line_in_ranges?(lineno, ranges)
|
|
22
|
+
line_text = line.chomp
|
|
23
|
+
evs = aggregate_events_for_line(events, lineno, line_text.length)
|
|
24
|
+
|
|
25
|
+
if evs.empty?
|
|
26
|
+
"<span class=\"line\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{esc(line_text)}</span>\n"
|
|
27
|
+
else
|
|
28
|
+
rendered = render_line_with_events(line_text, evs)
|
|
29
|
+
"<span class=\"line hit\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{rendered}</span>\n"
|
|
30
|
+
end
|
|
31
|
+
end.compact
|
|
32
|
+
|
|
33
|
+
<<~HTML
|
|
34
|
+
<!doctype html>
|
|
35
|
+
<html>
|
|
36
|
+
<head>
|
|
37
|
+
<meta charset="utf-8">
|
|
38
|
+
<title>Recorded Result View</title>
|
|
39
|
+
<style>
|
|
40
|
+
body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
|
|
41
|
+
.code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
|
|
42
|
+
.line { display: inline; padding: 2px 8px; }
|
|
43
|
+
.line:hover { background: #fff2c6; }
|
|
44
|
+
.line.hit { background: #f0ffe7; }
|
|
45
|
+
.ln { display: inline-block; width: 3em; color: #888; user-select: none; }
|
|
46
|
+
.hint { color: #666; margin-bottom: 8px; }
|
|
47
|
+
.expr { position: relative; display: inline-block; padding-bottom: 1px; }
|
|
48
|
+
.expr.hit { }
|
|
49
|
+
.expr.depth-1 { --hl: #7fbf7f; }
|
|
50
|
+
.expr.depth-2 { --hl: #6fa8ff; }
|
|
51
|
+
.expr.depth-3 { --hl: #ffb347; }
|
|
52
|
+
.expr.depth-4 { --hl: #d78bff; }
|
|
53
|
+
.expr.depth-5 { --hl: #ff6f91; }
|
|
54
|
+
.expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
|
|
55
|
+
.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; }
|
|
56
|
+
.marker .tooltip {
|
|
57
|
+
display: none;
|
|
58
|
+
position: absolute;
|
|
59
|
+
left: 0;
|
|
60
|
+
top: 100%;
|
|
61
|
+
margin-top: 4px;
|
|
62
|
+
background: #2b2b2b;
|
|
63
|
+
color: #fff;
|
|
64
|
+
padding: 4px 6px;
|
|
65
|
+
border-radius: 4px;
|
|
66
|
+
font-size: 12px;
|
|
67
|
+
white-space: pre;
|
|
68
|
+
min-width: 16ch;
|
|
69
|
+
max-width: 90vw;
|
|
70
|
+
overflow-x: auto;
|
|
71
|
+
overflow-y: hidden;
|
|
72
|
+
z-index: 10;
|
|
73
|
+
pointer-events: auto;
|
|
74
|
+
}
|
|
75
|
+
.marker:hover .tooltip,
|
|
76
|
+
.marker:focus-within .tooltip,
|
|
77
|
+
.marker .tooltip:hover { display: block; }
|
|
78
|
+
</style>
|
|
79
|
+
</head>
|
|
80
|
+
<body>
|
|
81
|
+
<div class="hint">Hover highlighted text to see recorded values.</div>
|
|
82
|
+
<pre class="code"><code>
|
|
83
|
+
#{html_lines.join("")}
|
|
84
|
+
</code></pre>
|
|
85
|
+
<script>
|
|
86
|
+
(function() {
|
|
87
|
+
document.querySelectorAll('.marker').forEach(marker => {
|
|
88
|
+
marker.addEventListener('mouseenter', () => {
|
|
89
|
+
document.querySelectorAll('.expr').forEach(e => e.classList.remove('active'));
|
|
90
|
+
marker.closest('.expr')?.classList.add('active');
|
|
91
|
+
});
|
|
92
|
+
marker.addEventListener('mouseleave', () => {
|
|
93
|
+
marker.closest('.expr')?.classList.remove('active');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
})();
|
|
97
|
+
</script>
|
|
98
|
+
</body>
|
|
99
|
+
</html>
|
|
100
|
+
HTML
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.esc(s)
|
|
104
|
+
s.to_s
|
|
105
|
+
.gsub("&", "&")
|
|
106
|
+
.gsub("<", "<")
|
|
107
|
+
.gsub(">", ">")
|
|
108
|
+
.gsub('"', """)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.format_value(v)
|
|
112
|
+
case v
|
|
113
|
+
when NilClass
|
|
114
|
+
"nil"
|
|
115
|
+
else
|
|
116
|
+
v.to_s
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.render_line_with_events(line_text, events)
|
|
121
|
+
opens = Hash.new { |h, k| h[k] = [] }
|
|
122
|
+
closes = Hash.new { |h, k| h[k] = [] }
|
|
123
|
+
|
|
124
|
+
events.each do |e|
|
|
125
|
+
s = e[:start_col].to_i
|
|
126
|
+
t = e[:end_col].to_i
|
|
127
|
+
next if t <= s
|
|
128
|
+
|
|
129
|
+
values = e[:values]
|
|
130
|
+
total = e[:total]
|
|
131
|
+
value_text = summarize_values(values, total)
|
|
132
|
+
tooltip_html = esc(value_text)
|
|
133
|
+
depth_class = "depth-#{e[:depth]}"
|
|
134
|
+
open_tag = "<span class=\"expr hit #{depth_class}\">"
|
|
135
|
+
close_tag = "<span class=\"marker\" aria-hidden=\"true\">🔎<span class=\"tooltip\">#{tooltip_html}</span></span></span>"
|
|
136
|
+
|
|
137
|
+
len = t - s
|
|
138
|
+
opens[s] << { len: len, start: s, end: t, tag: open_tag }
|
|
139
|
+
closes[t] << { len: len, start: s, end: t, tag: close_tag }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
positions = (opens.keys + closes.keys).uniq.sort
|
|
143
|
+
out = +""
|
|
144
|
+
cursor = 0
|
|
145
|
+
|
|
146
|
+
positions.each do |pos|
|
|
147
|
+
out << esc(line_text[cursor...pos]) if pos > cursor
|
|
148
|
+
if closes.key?(pos)
|
|
149
|
+
closes[pos].sort_by { |c| [-c[:start], c[:len]] }.each { |c| out << c[:tag] }
|
|
150
|
+
end
|
|
151
|
+
if opens.key?(pos)
|
|
152
|
+
opens[pos].sort_by { |o| -o[:end] }.each { |o| out << o[:tag] }
|
|
153
|
+
end
|
|
154
|
+
cursor = pos
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
out << esc(line_text[cursor..]) if cursor < line_text.length
|
|
158
|
+
out
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.summarize_values(values, total = nil)
|
|
162
|
+
return "" if values.nil? || values.empty?
|
|
163
|
+
total ||= values.length
|
|
164
|
+
last_vals = values.last(3)
|
|
165
|
+
first_index = total - last_vals.length + 1
|
|
166
|
+
lines = []
|
|
167
|
+
extra = total - last_vals.length
|
|
168
|
+
lines << "... (+#{extra} more)" if extra > 0
|
|
169
|
+
last_vals.each_with_index do |v, i|
|
|
170
|
+
idx = first_index + i
|
|
171
|
+
lines << "##{idx}: #{format_value(v)}"
|
|
172
|
+
end
|
|
173
|
+
lines.join("\n")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.aggregate_events_for_line(events, lineno, line_len)
|
|
177
|
+
buckets = {}
|
|
178
|
+
spans = []
|
|
179
|
+
|
|
180
|
+
events.each do |e|
|
|
181
|
+
sline = e[:start_line]
|
|
182
|
+
eline = e[:end_line]
|
|
183
|
+
next if lineno < sline || lineno > eline
|
|
184
|
+
|
|
185
|
+
if sline == eline
|
|
186
|
+
s = e[:start_col]
|
|
187
|
+
t = e[:end_col]
|
|
188
|
+
else
|
|
189
|
+
if lineno == sline
|
|
190
|
+
s = e[:start_col]
|
|
191
|
+
t = line_len
|
|
192
|
+
elsif lineno == eline
|
|
193
|
+
s = 0
|
|
194
|
+
t = e[:end_col]
|
|
195
|
+
else
|
|
196
|
+
s = 0
|
|
197
|
+
t = line_len
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
next if t <= s
|
|
202
|
+
spans << { start_col: s, end_col: t }
|
|
203
|
+
buckets[e[:key]] = {
|
|
204
|
+
key: e[:key],
|
|
205
|
+
start_col: s,
|
|
206
|
+
end_col: t,
|
|
207
|
+
values: e[:values],
|
|
208
|
+
total: e[:total]
|
|
209
|
+
}
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
buckets.values.each do |b|
|
|
213
|
+
depth = spans.count { |sp| b[:start_col] >= sp[:start_col] && b[:end_col] <= sp[:end_col] }
|
|
214
|
+
b[:depth] = [[depth, 1].max, 5].min
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
buckets.values.sort_by { |b| b[:start_col] }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def self.normalize_events(events)
|
|
221
|
+
merged = {}
|
|
222
|
+
events.each do |e|
|
|
223
|
+
file = e["file"] || e[:file]
|
|
224
|
+
start_line = e["start_line"] || e[:start_line]
|
|
225
|
+
start_col = e["start_col"] || e[:start_col]
|
|
226
|
+
end_line = e["end_line"] || e[:end_line]
|
|
227
|
+
end_col = e["end_col"] || e[:end_col]
|
|
228
|
+
key = [
|
|
229
|
+
file,
|
|
230
|
+
start_line.to_i,
|
|
231
|
+
start_col.to_i,
|
|
232
|
+
end_line.to_i,
|
|
233
|
+
end_col.to_i
|
|
234
|
+
]
|
|
235
|
+
entry = (merged[key] ||= {
|
|
236
|
+
key: key,
|
|
237
|
+
file: key[0],
|
|
238
|
+
start_line: key[1],
|
|
239
|
+
start_col: key[2],
|
|
240
|
+
end_line: key[3],
|
|
241
|
+
end_col: key[4],
|
|
242
|
+
values: [],
|
|
243
|
+
total: 0
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
vals = e["values"] || e[:values] || [e["value"] || e[:value]].compact
|
|
247
|
+
entry[:values].concat(vals)
|
|
248
|
+
entry[:total] += (e["total"] || e[:total] || vals.length)
|
|
249
|
+
end
|
|
250
|
+
merged.values
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def self.normalize_ranges(ranges)
|
|
254
|
+
return nil unless ranges
|
|
255
|
+
ranges.map do |r|
|
|
256
|
+
a = (r.respond_to?(:begin) ? r.begin : r[0]).to_i
|
|
257
|
+
b = (r.respond_to?(:end) ? r.end : r[1]).to_i
|
|
258
|
+
a <= b ? [a, b] : [b, a]
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def self.normalize_ranges_by_file(input)
|
|
263
|
+
return nil unless input
|
|
264
|
+
input.each_with_object({}) do |(file, ranges), h|
|
|
265
|
+
abs = File.expand_path(file)
|
|
266
|
+
if ranges.nil? || ranges.empty?
|
|
267
|
+
h[abs] = []
|
|
268
|
+
else
|
|
269
|
+
h[abs] = normalize_ranges(ranges)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def self.line_in_ranges?(line, ranges)
|
|
275
|
+
return true if ranges.empty?
|
|
276
|
+
ranges.any? { |(s, e)| line >= s && line <= e }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def self.render_all(events_path, root: Dir.pwd, ranges_by_file: nil)
|
|
280
|
+
raw_events = JSON.parse(File.read(events_path))
|
|
281
|
+
events = normalize_events(raw_events)
|
|
282
|
+
render_all_from_events(events, root: root, ranges_by_file: ranges_by_file)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def self.render_all_from_events(events, root: Dir.pwd, ranges_by_file: nil)
|
|
286
|
+
events = normalize_events(events)
|
|
287
|
+
by_file = events.group_by { |e| e[:file] }
|
|
288
|
+
ranges_by_file = normalize_ranges_by_file(ranges_by_file)
|
|
289
|
+
|
|
290
|
+
target_paths = if ranges_by_file
|
|
291
|
+
ranges_by_file.keys
|
|
292
|
+
else
|
|
293
|
+
by_file.keys
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
sections = target_paths.sort.map do |path|
|
|
297
|
+
next unless File.exist?(path)
|
|
298
|
+
src = File.read(path)
|
|
299
|
+
ranges = ranges_by_file ? (ranges_by_file[path] || []) : nil
|
|
300
|
+
html_lines = src.lines.each_with_index.map do |line, idx|
|
|
301
|
+
lineno = idx + 1
|
|
302
|
+
next if ranges && !line_in_ranges?(lineno, ranges)
|
|
303
|
+
line_text = line.chomp
|
|
304
|
+
evs = aggregate_events_for_line(by_file[path] || [], lineno, line_text.length)
|
|
305
|
+
if evs.empty?
|
|
306
|
+
"<span class=\"line\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{esc(line_text)}</span>\n"
|
|
307
|
+
else
|
|
308
|
+
rendered = render_line_with_events(line_text, evs)
|
|
309
|
+
"<span class=\"line hit\" data-line=\"#{lineno}\"><span class=\"ln\">#{lineno}</span> #{rendered}</span>\n"
|
|
310
|
+
end
|
|
311
|
+
end.compact
|
|
312
|
+
|
|
313
|
+
rel = path.start_with?(root) ? path.sub(root + File::SEPARATOR, "") : path
|
|
314
|
+
<<~HTML
|
|
315
|
+
<h2 class="file">#{esc(rel)}</h2>
|
|
316
|
+
<pre class="code"><code>
|
|
317
|
+
#{html_lines.join("")}
|
|
318
|
+
</code></pre>
|
|
319
|
+
HTML
|
|
320
|
+
end.compact.join("\n")
|
|
321
|
+
|
|
322
|
+
<<~HTML
|
|
323
|
+
<!doctype html>
|
|
324
|
+
<html>
|
|
325
|
+
<head>
|
|
326
|
+
<meta charset="utf-8">
|
|
327
|
+
<title>Recorded Result View</title>
|
|
328
|
+
<style>
|
|
329
|
+
body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #f7f5f0; color: #1f1f1f; padding: 24px; }
|
|
330
|
+
.code { background: #fffdf7; border: 1px solid #e5dfd0; border-radius: 8px; padding: 16px; line-height: 1.5; }
|
|
331
|
+
.line { display: inline; padding: 2px 8px; }
|
|
332
|
+
.line:hover { background: #fff2c6; }
|
|
333
|
+
.line.hit { background: #f0ffe7; }
|
|
334
|
+
.ln { display: inline-block; width: 3em; color: #888; user-select: none; }
|
|
335
|
+
.hint { color: #666; margin-bottom: 8px; }
|
|
336
|
+
.file { margin: 24px 0 8px; font-size: 16px; color: #333; }
|
|
337
|
+
.expr { position: relative; display: inline-block; padding-bottom: 1px; }
|
|
338
|
+
.expr.hit { }
|
|
339
|
+
.expr.depth-1 { --hl: #7fbf7f; }
|
|
340
|
+
.expr.depth-2 { --hl: #6fa8ff; }
|
|
341
|
+
.expr.depth-3 { --hl: #ffb347; }
|
|
342
|
+
.expr.depth-4 { --hl: #d78bff; }
|
|
343
|
+
.expr.depth-5 { --hl: #ff6f91; }
|
|
344
|
+
.expr.active { background: rgba(127, 191, 127, 0.15); box-shadow: inset 0 -2px var(--hl, #7fbf7f); }
|
|
345
|
+
.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; }
|
|
346
|
+
.marker .tooltip {
|
|
347
|
+
display: none;
|
|
348
|
+
position: absolute;
|
|
349
|
+
left: 0;
|
|
350
|
+
top: 100%;
|
|
351
|
+
margin-top: 4px;
|
|
352
|
+
background: #2b2b2b;
|
|
353
|
+
color: #fff;
|
|
354
|
+
padding: 4px 6px;
|
|
355
|
+
border-radius: 4px;
|
|
356
|
+
font-size: 12px;
|
|
357
|
+
white-space: pre;
|
|
358
|
+
min-width: 16ch;
|
|
359
|
+
max-width: 90vw;
|
|
360
|
+
overflow-x: auto;
|
|
361
|
+
overflow-y: hidden;
|
|
362
|
+
z-index: 10;
|
|
363
|
+
pointer-events: auto;
|
|
364
|
+
}
|
|
365
|
+
.marker:hover .tooltip,
|
|
366
|
+
.marker:focus-within .tooltip,
|
|
367
|
+
.marker .tooltip:hover { display: block; }
|
|
368
|
+
</style>
|
|
369
|
+
</head>
|
|
370
|
+
<body>
|
|
371
|
+
<div class="hint">Hover highlighted text to see recorded values.</div>
|
|
372
|
+
#{sections}
|
|
373
|
+
<script>
|
|
374
|
+
(function() {
|
|
375
|
+
document.querySelectorAll('.marker').forEach(marker => {
|
|
376
|
+
marker.addEventListener('mouseenter', () => {
|
|
377
|
+
document.querySelectorAll('.expr').forEach(e => e.classList.remove('active'));
|
|
378
|
+
marker.closest('.expr')?.classList.add('active');
|
|
379
|
+
});
|
|
380
|
+
marker.addEventListener('mouseleave', () => {
|
|
381
|
+
marker.closest('.expr')?.classList.remove('active');
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
})();
|
|
385
|
+
</script>
|
|
386
|
+
</body>
|
|
387
|
+
</html>
|
|
388
|
+
HTML
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
if $PROGRAM_NAME == __FILE__
|
|
393
|
+
source_path = ARGV[0] or abort "usage: ruby generate_resulted_html.rb SOURCE_PATH EVENTS_PATH"
|
|
394
|
+
events_path = ARGV[1] or abort "usage: ruby generate_resulted_html.rb SOURCE_PATH EVENTS_PATH"
|
|
395
|
+
puts GenerateResultedHtml.render(source_path, events_path)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "prism"
|
|
3
|
+
|
|
4
|
+
module Lumitrace
|
|
5
|
+
module RecordInstrument
|
|
6
|
+
SKIP_NODE_CLASSES = [
|
|
7
|
+
Prism::DefNode,
|
|
8
|
+
Prism::ClassNode,
|
|
9
|
+
Prism::ModuleNode,
|
|
10
|
+
Prism::IfNode,
|
|
11
|
+
Prism::UnlessNode,
|
|
12
|
+
Prism::WhileNode,
|
|
13
|
+
Prism::UntilNode,
|
|
14
|
+
Prism::ForNode,
|
|
15
|
+
Prism::CaseNode,
|
|
16
|
+
Prism::BeginNode,
|
|
17
|
+
Prism::RescueNode,
|
|
18
|
+
Prism::EnsureNode,
|
|
19
|
+
Prism::AliasMethodNode,
|
|
20
|
+
Prism::UndefNode
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
LITERAL_NODE_CLASSES = [
|
|
24
|
+
Prism::IntegerNode,
|
|
25
|
+
Prism::FloatNode,
|
|
26
|
+
Prism::RationalNode,
|
|
27
|
+
Prism::ImaginaryNode,
|
|
28
|
+
Prism::StringNode,
|
|
29
|
+
Prism::SymbolNode,
|
|
30
|
+
Prism::TrueNode,
|
|
31
|
+
Prism::FalseNode,
|
|
32
|
+
Prism::NilNode
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
WRAP_NODE_CLASSES = [
|
|
36
|
+
Prism::CallNode,
|
|
37
|
+
Prism::LocalVariableReadNode,
|
|
38
|
+
Prism::ConstantReadNode,
|
|
39
|
+
Prism::InstanceVariableReadNode,
|
|
40
|
+
Prism::ClassVariableReadNode,
|
|
41
|
+
Prism::GlobalVariableReadNode
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
def self.instrument_source(src, ranges, file_label: nil, record_method: "Lumitrace::RecordInstrument.expr_record")
|
|
45
|
+
file_label ||= "(unknown)"
|
|
46
|
+
ranges = normalize_ranges(ranges)
|
|
47
|
+
|
|
48
|
+
parse = Prism.parse(src)
|
|
49
|
+
if parse.errors.any?
|
|
50
|
+
raise "parse errors: #{parse.errors.map(&:message).join(", ") }"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
inserts = collect_inserts(parse.value, src, ranges, file_label, record_method)
|
|
54
|
+
|
|
55
|
+
apply_insertions(src, inserts)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.collect_inserts(root, src, ranges, file_label, record_method)
|
|
59
|
+
inserts = []
|
|
60
|
+
stack = [[root, nil]]
|
|
61
|
+
|
|
62
|
+
until stack.empty?
|
|
63
|
+
node, parent = stack.pop
|
|
64
|
+
next unless node
|
|
65
|
+
|
|
66
|
+
if node.respond_to?(:location)
|
|
67
|
+
line = node.location.start_line
|
|
68
|
+
if in_ranges?(line, ranges) && wrap_expr?(node, parent)
|
|
69
|
+
loc = expr_location(node)
|
|
70
|
+
prefix = "#{record_method}(\"#{file_label}\", #{loc[:start_line]}, #{loc[:start_col]}, #{loc[:end_line]}, #{loc[:end_col]}, ("
|
|
71
|
+
suffix = "))"
|
|
72
|
+
span_len = loc[:end_offset] - loc[:start_offset]
|
|
73
|
+
inserts << { pos: loc[:start_offset], text: prefix, kind: :open, len: span_len }
|
|
74
|
+
inserts << { pos: loc[:end_offset], text: suffix, kind: :close, len: span_len }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
node.child_nodes.each { |child| stack << [child, node] }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
inserts
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.normalize_ranges(ranges)
|
|
85
|
+
ranges.map do |r|
|
|
86
|
+
a = r[0].to_i
|
|
87
|
+
b = r[1].to_i
|
|
88
|
+
a <= b ? [a, b] : [b, a]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.in_ranges?(line, ranges)
|
|
93
|
+
return true if ranges.empty?
|
|
94
|
+
ranges.any? { |(s, e)| line >= s && line <= e }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.apply_insertions(src, inserts)
|
|
98
|
+
out = src.dup
|
|
99
|
+
kind_order = { open: 0, close: 1 }
|
|
100
|
+
inserts.sort_by do |i|
|
|
101
|
+
[
|
|
102
|
+
-i[:pos],
|
|
103
|
+
kind_order[i[:kind]],
|
|
104
|
+
i[:kind] == :open ? i[:len] : -i[:len]
|
|
105
|
+
]
|
|
106
|
+
end.each do |i|
|
|
107
|
+
out.insert(i[:pos], i[:text])
|
|
108
|
+
end
|
|
109
|
+
out
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.literal_value_node?(node)
|
|
113
|
+
LITERAL_NODE_CLASSES.include?(node.class)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.wrap_expr?(node, parent = nil)
|
|
117
|
+
return false unless node.respond_to?(:location)
|
|
118
|
+
return false if literal_value_node?(node)
|
|
119
|
+
return false if node.is_a?(Prism::CallNode) && has_block_with_body?(node)
|
|
120
|
+
if node.is_a?(Prism::ConstantReadNode) &&
|
|
121
|
+
(parent.is_a?(Prism::ClassNode) || parent.is_a?(Prism::ModuleNode))
|
|
122
|
+
return false
|
|
123
|
+
end
|
|
124
|
+
WRAP_NODE_CLASSES.include?(node.class)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.expr_location(node)
|
|
128
|
+
loc = node.location
|
|
129
|
+
return {
|
|
130
|
+
start_offset: loc.start_offset,
|
|
131
|
+
end_offset: loc.start_offset + loc.length,
|
|
132
|
+
start_line: loc.start_line,
|
|
133
|
+
start_col: loc.start_column,
|
|
134
|
+
end_line: loc.end_line,
|
|
135
|
+
end_col: loc.end_column
|
|
136
|
+
} unless node.is_a?(Prism::CallNode)
|
|
137
|
+
|
|
138
|
+
best = loc
|
|
139
|
+
[node.arguments&.location, node.block&.location, node.closing_loc].compact.each do |l|
|
|
140
|
+
next unless l
|
|
141
|
+
best = l if (l.start_offset + l.length) >= (best.start_offset + best.length)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
start_offset: loc.start_offset,
|
|
146
|
+
end_offset: best.start_offset + best.length,
|
|
147
|
+
start_line: loc.start_line,
|
|
148
|
+
start_col: loc.start_column,
|
|
149
|
+
end_line: best.end_line,
|
|
150
|
+
end_col: best.end_column
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def self.has_block_with_body?(call_node)
|
|
155
|
+
block = call_node.child_nodes.find { |n| n.is_a?(Prism::BlockNode) }
|
|
156
|
+
block && block.body.is_a?(Prism::StatementsNode)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
@events_by_key = {}
|
|
160
|
+
@max_values_per_expr = 3
|
|
161
|
+
|
|
162
|
+
def self.max_values_per_expr=(n)
|
|
163
|
+
@max_values_per_expr = n.to_i if n && n.to_i > 0
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def self.max_values_per_expr
|
|
167
|
+
@max_values_per_expr
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.expr_record(file, start_line, start_col, end_line, end_col, value)
|
|
171
|
+
key = [file, start_line, start_col, end_line, end_col]
|
|
172
|
+
entry = (@events_by_key[key] ||= {
|
|
173
|
+
file: file,
|
|
174
|
+
start_line: start_line,
|
|
175
|
+
start_col: start_col,
|
|
176
|
+
end_line: end_line,
|
|
177
|
+
end_col: end_col,
|
|
178
|
+
values: [],
|
|
179
|
+
total: 0
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
entry[:total] += 1
|
|
183
|
+
entry[:values] << safe_value(value)
|
|
184
|
+
if entry[:values].length > @max_values_per_expr
|
|
185
|
+
entry[:values].shift(entry[:values].length - @max_values_per_expr)
|
|
186
|
+
end
|
|
187
|
+
value
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def self.dump_json(path = nil)
|
|
191
|
+
path ||= File.expand_path("lumitrace_recorded.json", Dir.pwd)
|
|
192
|
+
File.write(path, JSON.dump(@events_by_key.values))
|
|
193
|
+
path
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self.events
|
|
197
|
+
@events_by_key.values.map do |e|
|
|
198
|
+
{
|
|
199
|
+
file: e[:file],
|
|
200
|
+
start_line: e[:start_line],
|
|
201
|
+
start_col: e[:start_col],
|
|
202
|
+
end_line: e[:end_line],
|
|
203
|
+
end_col: e[:end_col],
|
|
204
|
+
values: e[:values].dup,
|
|
205
|
+
total: e[:total]
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def self.safe_value(v)
|
|
211
|
+
case v
|
|
212
|
+
when String
|
|
213
|
+
v.bytesize > 1000 ? v[0, 1000] + "..." : v
|
|
214
|
+
when Numeric, TrueClass, FalseClass, NilClass
|
|
215
|
+
v
|
|
216
|
+
else
|
|
217
|
+
v.inspect
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if $PROGRAM_NAME == __FILE__
|
|
224
|
+
path = ARGV[0] or abort "usage: ruby record_instrument.rb FILE RANGES_JSON [record_method] [out_path]"
|
|
225
|
+
ranges = JSON.parse(ARGV[1] || "[]")
|
|
226
|
+
record_method = ARGV[2] || "Lumitrace::RecordInstrument.expr_record"
|
|
227
|
+
out = RecordInstrument.instrument_source(File.read(path), ranges, file_label: path, record_method: record_method)
|
|
228
|
+
|
|
229
|
+
if ARGV[3]
|
|
230
|
+
File.write(ARGV[3], out)
|
|
231
|
+
else
|
|
232
|
+
puts out
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "prism"
|
|
3
|
+
require_relative "./record_instrument"
|
|
4
|
+
|
|
5
|
+
module Lumitrace
|
|
6
|
+
module RecordRequire
|
|
7
|
+
@enabled = false
|
|
8
|
+
@processed = {}
|
|
9
|
+
@root = File.expand_path(ENV.fetch("LUMITRACE_ROOT", Dir.pwd))
|
|
10
|
+
@tool_root = File.expand_path(__dir__)
|
|
11
|
+
@ranges_by_file = {}
|
|
12
|
+
@ranges_filtering = false
|
|
13
|
+
|
|
14
|
+
def self.enable(max_values: nil, ranges_by_file: nil)
|
|
15
|
+
return if @enabled
|
|
16
|
+
RecordInstrument.max_values_per_expr = max_values if max_values
|
|
17
|
+
if ranges_by_file
|
|
18
|
+
@ranges_by_file = normalize_ranges_by_file(ranges_by_file)
|
|
19
|
+
@ranges_filtering = true
|
|
20
|
+
else
|
|
21
|
+
@ranges_by_file = {}
|
|
22
|
+
@ranges_filtering = false
|
|
23
|
+
end
|
|
24
|
+
@enabled = true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.ranges_for(path)
|
|
28
|
+
return [] unless @ranges_filtering
|
|
29
|
+
@ranges_by_file[File.expand_path(path)] || []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.ranges_filtering?
|
|
33
|
+
@ranges_filtering
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.listed_file?(path)
|
|
37
|
+
@ranges_by_file.key?(File.expand_path(path))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.normalize_ranges_by_file(input)
|
|
41
|
+
return {} unless input
|
|
42
|
+
input.each_with_object({}) do |(file, ranges), h|
|
|
43
|
+
next unless file
|
|
44
|
+
abs = File.expand_path(file)
|
|
45
|
+
if ranges.nil? || ranges.empty?
|
|
46
|
+
h[abs] = []
|
|
47
|
+
else
|
|
48
|
+
h[abs] = ranges.map { |r| [r.begin, r.end] }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.disable
|
|
54
|
+
@enabled = false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.enabled?
|
|
58
|
+
@enabled
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.in_root?(path)
|
|
62
|
+
abs = File.expand_path(path)
|
|
63
|
+
abs.start_with?(@root + File::SEPARATOR)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.excluded?(path)
|
|
67
|
+
abs = File.expand_path(path)
|
|
68
|
+
abs.start_with?(@tool_root + File::SEPARATOR)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.already_processed?(path)
|
|
72
|
+
@processed[path]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.mark_processed(path)
|
|
76
|
+
@processed[path] = true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if defined?(RubyVM::InstructionSequence)
|
|
81
|
+
class RubyVM::InstructionSequence
|
|
82
|
+
class << self
|
|
83
|
+
if respond_to?(:translate)
|
|
84
|
+
alias_method :recordrequire_orig_translate, :translate
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def translate(iseq)
|
|
88
|
+
return recordrequire_orig_translate(iseq) if respond_to?(:recordrequire_orig_translate) && !RecordRequire.enabled?
|
|
89
|
+
path = iseq.path
|
|
90
|
+
abs = File.expand_path(path)
|
|
91
|
+
if RecordRequire.in_root?(abs) && !RecordRequire.excluded?(abs) && !RecordRequire.already_processed?(abs) &&
|
|
92
|
+
(iseq.label == "<main>" || iseq.label == "<top (required)>")
|
|
93
|
+
if RecordRequire.ranges_filtering? && !RecordRequire.listed_file?(abs)
|
|
94
|
+
return recordrequire_orig_translate(iseq) if respond_to?(:recordrequire_orig_translate)
|
|
95
|
+
return nil
|
|
96
|
+
end
|
|
97
|
+
RecordRequire.mark_processed(abs)
|
|
98
|
+
src = File.read(abs)
|
|
99
|
+
ranges = RecordRequire.ranges_for(abs)
|
|
100
|
+
modified = RecordInstrument.instrument_source(src, ranges, file_label: abs)
|
|
101
|
+
return RubyVM::InstructionSequence.compile(modified, abs)
|
|
102
|
+
end
|
|
103
|
+
return recordrequire_orig_translate(iseq) if respond_to?(:recordrequire_orig_translate)
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
end
|
data/lib/lumitrace.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lumitrace/version"
|
|
4
|
+
require_relative "lumitrace/record_instrument"
|
|
5
|
+
require_relative "lumitrace/generate_resulted_html"
|
|
6
|
+
|
|
7
|
+
module Lumitrace
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
@atexit_registered = false
|
|
10
|
+
@atexit_output_root = nil
|
|
11
|
+
@atexit_ranges_by_file = nil
|
|
12
|
+
|
|
13
|
+
def self.enable!(max_values: ENV["LUMITRACE_VALUES_MAX"], ranges_by_file: nil, at_exit: true)
|
|
14
|
+
require_relative "lumitrace/record_require"
|
|
15
|
+
RecordRequire.enable(max_values: max_values, ranges_by_file: ranges_by_file)
|
|
16
|
+
if at_exit
|
|
17
|
+
@atexit_output_root = Dir.pwd
|
|
18
|
+
@atexit_ranges_by_file = ranges_by_file
|
|
19
|
+
unless @atexit_registered
|
|
20
|
+
at_exit do
|
|
21
|
+
next unless RecordRequire.enabled?
|
|
22
|
+
if ENV["LUMITRACE_JSON_OUT"] && !ENV["LUMITRACE_JSON_OUT"].empty?
|
|
23
|
+
RecordInstrument.dump_json(File.expand_path(ENV["LUMITRACE_JSON_OUT"], @atexit_output_root))
|
|
24
|
+
end
|
|
25
|
+
events = RecordInstrument.events
|
|
26
|
+
html = GenerateResultedHtml.render_all_from_events(
|
|
27
|
+
events,
|
|
28
|
+
root: @atexit_output_root,
|
|
29
|
+
ranges_by_file: @atexit_ranges_by_file
|
|
30
|
+
)
|
|
31
|
+
out_path = ENV.fetch("LUMITRACE_HTML_OUT", File.expand_path("lumitrace_recorded.html", @atexit_output_root))
|
|
32
|
+
File.write(out_path, html)
|
|
33
|
+
end
|
|
34
|
+
@atexit_registered = true
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.disable!
|
|
40
|
+
return unless defined?(RecordRequire)
|
|
41
|
+
RecordRequire.disable
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: Lumitrace Sample
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
run-lumitrace:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- name: Checkout
|
|
12
|
+
uses: actions/checkout@v4
|
|
13
|
+
with:
|
|
14
|
+
fetch-depth: 0
|
|
15
|
+
|
|
16
|
+
- name: Setup Ruby
|
|
17
|
+
uses: ruby/setup-ruby@v1
|
|
18
|
+
with:
|
|
19
|
+
ruby-version: "3.2"
|
|
20
|
+
|
|
21
|
+
- name: Install dependencies
|
|
22
|
+
run: bundle install
|
|
23
|
+
|
|
24
|
+
- name: Run tests with Lumitrace
|
|
25
|
+
env:
|
|
26
|
+
LUMITRACE_GIT_DIFF: "range:origin/${{ github.base_ref || HEAD~1 }}...HEAD"
|
|
27
|
+
LUMITRACE_HTML_OUT: "${{ github.workspace }}/lumitrace_recorded.html"
|
|
28
|
+
run: RUBYOPT='-r lumitrace/enable_git_diff' bundle exec rake test
|
|
29
|
+
|
|
30
|
+
- name: Upload Lumitrace HTML
|
|
31
|
+
uses: actions/upload-artifact@v4
|
|
32
|
+
with:
|
|
33
|
+
name: lumitrace-recorded-html
|
|
34
|
+
path: lumitrace_recorded.html
|
|
35
|
+
|
|
36
|
+
- name: Artifact URL
|
|
37
|
+
run: |
|
|
38
|
+
echo "Artifact URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_STEP_SUMMARY
|
data/sig/lumitrace.rbs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "json"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require_relative "../lib/lumitrace"
|
|
7
|
+
|
|
8
|
+
class LumiTraceTest < Minitest::Test
|
|
9
|
+
def test_namespaces_loaded
|
|
10
|
+
assert defined?(Lumitrace::RecordInstrument)
|
|
11
|
+
assert defined?(Lumitrace::GenerateResultedHtml)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def test_instrument_wraps_calls_and_reads
|
|
15
|
+
src = <<~RUBY
|
|
16
|
+
def compute(x)
|
|
17
|
+
v1 = add(x, 2)
|
|
18
|
+
v2 = mul(v1, 3)
|
|
19
|
+
end
|
|
20
|
+
RUBY
|
|
21
|
+
|
|
22
|
+
out = Lumitrace::RecordInstrument.instrument_source(src, [], file_label: "sample.rb")
|
|
23
|
+
|
|
24
|
+
assert_includes out, "Lumitrace::RecordInstrument.expr_record(\"sample.rb\", 2, 7,"
|
|
25
|
+
assert_includes out, "Lumitrace::RecordInstrument.expr_record(\"sample.rb\", 2, 11,"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_render_all_generates_html
|
|
29
|
+
Dir.mktmpdir do |dir|
|
|
30
|
+
path = File.join(dir, "lumitrace_events.json")
|
|
31
|
+
sample = File.join(dir, "sample.rb")
|
|
32
|
+
events = [
|
|
33
|
+
{
|
|
34
|
+
"file" => sample,
|
|
35
|
+
"start_line" => 1,
|
|
36
|
+
"start_col" => 0,
|
|
37
|
+
"end_line" => 1,
|
|
38
|
+
"end_col" => 5,
|
|
39
|
+
"values" => ["ok"],
|
|
40
|
+
"total" => 1
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
File.write(path, JSON.dump(events))
|
|
45
|
+
File.write(sample, "puts hi\n")
|
|
46
|
+
|
|
47
|
+
html = Lumitrace::GenerateResultedHtml.render_all(path, root: dir)
|
|
48
|
+
assert_includes html, "Recorded Result View"
|
|
49
|
+
assert_includes html, "sample.rb"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_require_instruments_multiple_files
|
|
54
|
+
skip "RubyVM::InstructionSequence unavailable" unless defined?(RubyVM::InstructionSequence)
|
|
55
|
+
|
|
56
|
+
Dir.mktmpdir do |dir|
|
|
57
|
+
ENV["LUMITRACE_ROOT"] = dir
|
|
58
|
+
|
|
59
|
+
main = File.join(dir, "main.rb")
|
|
60
|
+
sub = File.join(dir, "sub.rb")
|
|
61
|
+
|
|
62
|
+
File.write(sub, <<~RUBY)
|
|
63
|
+
def sub_value(x)
|
|
64
|
+
x + 1
|
|
65
|
+
end
|
|
66
|
+
RUBY
|
|
67
|
+
|
|
68
|
+
File.write(main, <<~RUBY)
|
|
69
|
+
require_relative "./sub"
|
|
70
|
+
sub_value(10)
|
|
71
|
+
RUBY
|
|
72
|
+
|
|
73
|
+
Lumitrace.enable!(max_values: 3, at_exit: false)
|
|
74
|
+
Lumitrace::RecordInstrument.instance_variable_set(:@events_by_key, {})
|
|
75
|
+
|
|
76
|
+
load main
|
|
77
|
+
|
|
78
|
+
out = File.join(dir, "events.json")
|
|
79
|
+
Lumitrace::RecordInstrument.dump_json(out)
|
|
80
|
+
events = JSON.parse(File.read(out))
|
|
81
|
+
files = events.map { |e| e["file"] }.uniq
|
|
82
|
+
|
|
83
|
+
assert_includes files, main
|
|
84
|
+
assert_includes files, sub
|
|
85
|
+
end
|
|
86
|
+
ensure
|
|
87
|
+
ENV.delete("LUMITRACE_ROOT")
|
|
88
|
+
end
|
|
89
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lumitrace
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Koichi Sasada
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Inline expression tracing for Ruby
|
|
13
|
+
email:
|
|
14
|
+
- ko1@atdot.net
|
|
15
|
+
executables:
|
|
16
|
+
- lumitrace
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- README.md
|
|
21
|
+
- Rakefile
|
|
22
|
+
- exe/lumitrace
|
|
23
|
+
- lib/lumitrace.rb
|
|
24
|
+
- lib/lumitrace/enable.rb
|
|
25
|
+
- lib/lumitrace/enable_git_diff.rb
|
|
26
|
+
- lib/lumitrace/generate_resulted_html.rb
|
|
27
|
+
- lib/lumitrace/record_instrument.rb
|
|
28
|
+
- lib/lumitrace/record_require.rb
|
|
29
|
+
- lib/lumitrace/version.rb
|
|
30
|
+
- sample_project/.github/workflows/lumitrace-sample.yml
|
|
31
|
+
- sample_project/Gemfile
|
|
32
|
+
- sample_project/Rakefile
|
|
33
|
+
- sample_project/test.rb
|
|
34
|
+
- sample_project/test/test_sample_test.rb
|
|
35
|
+
- sig/lumitrace.rbs
|
|
36
|
+
- test/test_lumitrace.rb
|
|
37
|
+
homepage: https://github.com/ko1/lumitrace
|
|
38
|
+
licenses: []
|
|
39
|
+
metadata:
|
|
40
|
+
homepage_uri: https://github.com/ko1/lumitrace
|
|
41
|
+
source_code_uri: https://github.com/ko1/lumitrace
|
|
42
|
+
rdoc_options: []
|
|
43
|
+
require_paths:
|
|
44
|
+
- lib
|
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: 3.2.0
|
|
50
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
requirements: []
|
|
56
|
+
rubygems_version: 4.0.3
|
|
57
|
+
specification_version: 4
|
|
58
|
+
summary: Inline expression tracing for Ruby
|
|
59
|
+
test_files: []
|