markdown-run 0.1.10 → 0.1.12
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 +4 -4
- data/CHANGELOG.md +26 -15
- data/README.md +116 -0
- data/Rakefile +10 -0
- data/lib/code_block_helper.rb +64 -0
- data/lib/code_block_parser.rb +17 -8
- data/lib/code_executor.rb +102 -24
- data/lib/dalibo_helper.rb +39 -0
- data/lib/execution_decider.rb +83 -9
- data/lib/flamegraph_helper.rb +39 -0
- data/lib/frontmatter_parser.rb +48 -6
- data/lib/language_configs.rb +10 -10
- data/lib/markdown/run/version.rb +1 -1
- data/lib/markdown_processor.rb +9 -259
- data/lib/pg_flamegraph_svg.rb +221 -0
- data/lib/result_helper.rb +167 -0
- data/markdown-run-sample.md +9 -0
- metadata +21 -2
@@ -0,0 +1,221 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'securerandom'
|
3
|
+
|
4
|
+
class PostgreSQLFlameGraphSVG
|
5
|
+
def initialize(explain_json, width = 1200, height = 600)
|
6
|
+
@explain_data = JSON.parse(explain_json)
|
7
|
+
@width = width
|
8
|
+
@height = height
|
9
|
+
@font_size = 20
|
10
|
+
@min_width = 1
|
11
|
+
@colors = [
|
12
|
+
'#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6',
|
13
|
+
'#1abc9c', '#e67e22', '#95a5a6', '#34495e', '#e91e63'
|
14
|
+
]
|
15
|
+
end
|
16
|
+
|
17
|
+
def generate_svg
|
18
|
+
plan = @explain_data[0]['Plan']
|
19
|
+
|
20
|
+
# Calculate the layout
|
21
|
+
flamegraph_data = build_flamegraph_data(plan)
|
22
|
+
|
23
|
+
# Generate SVG
|
24
|
+
generate_svg_content(flamegraph_data)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def build_flamegraph_data(plan, depth = 0, start_time = 0)
|
30
|
+
node_name = format_node_name(plan)
|
31
|
+
actual_time = plan['Actual Total Time'] || 0
|
32
|
+
|
33
|
+
# Create the current node
|
34
|
+
current_node = {
|
35
|
+
name: node_name,
|
36
|
+
time: actual_time,
|
37
|
+
depth: depth,
|
38
|
+
start: start_time,
|
39
|
+
children: []
|
40
|
+
}
|
41
|
+
|
42
|
+
# Process children
|
43
|
+
if plan['Plans']
|
44
|
+
child_start = start_time
|
45
|
+
plan['Plans'].each do |child_plan|
|
46
|
+
child_node = build_flamegraph_data(child_plan, depth + 1, child_start)
|
47
|
+
current_node[:children] << child_node
|
48
|
+
child_start += child_node[:time]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
current_node
|
53
|
+
end
|
54
|
+
|
55
|
+
def format_node_name(node)
|
56
|
+
node_type = node['Node Type']
|
57
|
+
|
58
|
+
# Add relevant details
|
59
|
+
details = []
|
60
|
+
|
61
|
+
if node['Relation Name']
|
62
|
+
details << node['Relation Name']
|
63
|
+
end
|
64
|
+
|
65
|
+
if node['Index Name']
|
66
|
+
details << "idx:#{node['Index Name']}"
|
67
|
+
end
|
68
|
+
|
69
|
+
if node['Join Type']
|
70
|
+
details << node['Join Type']
|
71
|
+
end
|
72
|
+
|
73
|
+
# Build the final name
|
74
|
+
name = node_type
|
75
|
+
unless details.empty?
|
76
|
+
name += " (#{details.join(', ')})"
|
77
|
+
end
|
78
|
+
|
79
|
+
# Add timing info
|
80
|
+
if node['Actual Total Time']
|
81
|
+
name += " [#{node['Actual Total Time'].round(2)}ms]"
|
82
|
+
end
|
83
|
+
|
84
|
+
name
|
85
|
+
end
|
86
|
+
|
87
|
+
def generate_svg_content(flamegraph_data)
|
88
|
+
max_depth = calculate_max_depth(flamegraph_data)
|
89
|
+
total_time = flamegraph_data[:time]
|
90
|
+
|
91
|
+
svg_height = (max_depth + 1) * (@font_size + 4) + 80
|
92
|
+
|
93
|
+
svg = <<~SVG
|
94
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
95
|
+
<svg width="#{@width}" height="#{svg_height}" xmlns="http://www.w3.org/2000/svg">
|
96
|
+
<style>
|
97
|
+
.frame { stroke: white; stroke-width: 1; cursor: pointer; }
|
98
|
+
.frame:hover { stroke: black; stroke-width: 2; }
|
99
|
+
.frame-text { font-family: monospace; font-size: #{@font_size}px; fill: white; pointer-events: none; }
|
100
|
+
.title { font-family: Arial; font-size: 16px; font-weight: bold; fill: #333; }
|
101
|
+
.subtitle { font-family: Arial; font-size: 12px; fill: #666; }
|
102
|
+
</style>
|
103
|
+
|
104
|
+
<!-- Title -->
|
105
|
+
<text x="#{@width/2}" y="20" class="title" text-anchor="middle">PostgreSQL Query Execution Plan Flamegraph</text>
|
106
|
+
<text x="#{@width/2}" y="35" class="subtitle" text-anchor="middle">Total Execution Time: #{total_time.round(2)}ms</text>
|
107
|
+
|
108
|
+
<!-- Flamegraph -->
|
109
|
+
<g transform="translate(0, 45)">
|
110
|
+
SVG
|
111
|
+
|
112
|
+
# Generate rectangles recursively
|
113
|
+
svg += generate_rectangles(flamegraph_data, total_time, 0)
|
114
|
+
|
115
|
+
svg += <<~SVG
|
116
|
+
</g>
|
117
|
+
</svg>
|
118
|
+
SVG
|
119
|
+
|
120
|
+
svg
|
121
|
+
end
|
122
|
+
|
123
|
+
def generate_rectangles(node, total_time, y_offset)
|
124
|
+
return "" if node[:time] <= 0
|
125
|
+
|
126
|
+
# Calculate dimensions
|
127
|
+
width_ratio = node[:time] / total_time
|
128
|
+
rect_width = [@width * width_ratio, @min_width].max
|
129
|
+
rect_height = @font_size + 4
|
130
|
+
|
131
|
+
x_position = (node[:start] / total_time) * @width
|
132
|
+
y_position = y_offset
|
133
|
+
|
134
|
+
# Choose color based on node type
|
135
|
+
color = get_node_color(node[:name])
|
136
|
+
|
137
|
+
# Generate rectangle and text
|
138
|
+
svg = <<~SVG
|
139
|
+
<rect class="frame"
|
140
|
+
x="#{x_position}"
|
141
|
+
y="#{y_position}"
|
142
|
+
width="#{rect_width}"
|
143
|
+
height="#{rect_height}"
|
144
|
+
fill="#{color}">
|
145
|
+
<title>#{escape_xml(node[:name])}
|
146
|
+
Time: #{node[:time].round(2)}ms
|
147
|
+
Percentage: #{((node[:time] / total_time) * 100).round(1)}%</title>
|
148
|
+
</rect>
|
149
|
+
SVG
|
150
|
+
|
151
|
+
# Add text if rectangle is wide enough
|
152
|
+
if rect_width > 50
|
153
|
+
text_x = x_position + 4
|
154
|
+
text_y = y_position + @font_size + 1
|
155
|
+
|
156
|
+
# Truncate text if necessary
|
157
|
+
display_text = truncate_text(node[:name], rect_width - 8)
|
158
|
+
|
159
|
+
svg += <<~SVG
|
160
|
+
<text class="frame-text" x="#{text_x}" y="#{text_y}">#{escape_xml(display_text)}</text>
|
161
|
+
SVG
|
162
|
+
end
|
163
|
+
|
164
|
+
# Generate children
|
165
|
+
child_y = y_position + rect_height + 2
|
166
|
+
node[:children].each do |child|
|
167
|
+
svg += generate_rectangles(child, total_time, child_y)
|
168
|
+
end
|
169
|
+
|
170
|
+
svg
|
171
|
+
end
|
172
|
+
|
173
|
+
def calculate_max_depth(node, current_depth = 0)
|
174
|
+
max_child_depth = current_depth
|
175
|
+
|
176
|
+
node[:children].each do |child|
|
177
|
+
child_depth = calculate_max_depth(child, current_depth + 1)
|
178
|
+
max_child_depth = [max_child_depth, child_depth].max
|
179
|
+
end
|
180
|
+
|
181
|
+
max_child_depth
|
182
|
+
end
|
183
|
+
|
184
|
+
def get_node_color(node_name)
|
185
|
+
# Color code by operation type
|
186
|
+
case node_name
|
187
|
+
when /Seq Scan/
|
188
|
+
'#e74c3c' # Red - potentially slow
|
189
|
+
when /Index.*Scan/
|
190
|
+
'#2ecc71' # Green - good
|
191
|
+
when /Hash Join|Nested Loop|Merge Join/
|
192
|
+
'#3498db' # Blue - joins
|
193
|
+
when /Sort|Aggregate/
|
194
|
+
'#f39c12' # Orange - processing
|
195
|
+
when /Result/
|
196
|
+
'#95a5a6' # Gray - simple
|
197
|
+
else
|
198
|
+
# Cycle through colors based on hash
|
199
|
+
@colors[node_name.hash.abs % @colors.length]
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def truncate_text(text, max_width)
|
204
|
+
# Rough estimate: 1 character ≈ 7 pixels in monospace
|
205
|
+
max_chars = (max_width / 7).to_i
|
206
|
+
|
207
|
+
if text.length <= max_chars
|
208
|
+
text
|
209
|
+
else
|
210
|
+
text[0, max_chars - 3] + "..."
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
def escape_xml(text)
|
215
|
+
text.gsub('&', '&')
|
216
|
+
.gsub('<', '<')
|
217
|
+
.gsub('>', '>')
|
218
|
+
.gsub('"', '"')
|
219
|
+
.gsub("'", ''')
|
220
|
+
end
|
221
|
+
end
|
@@ -0,0 +1,167 @@
|
|
1
|
+
require_relative "flamegraph_helper"
|
2
|
+
|
3
|
+
module ResultHelper
|
4
|
+
include FlamegraphHelper
|
5
|
+
|
6
|
+
private
|
7
|
+
|
8
|
+
def ruby_style_result?(lang)
|
9
|
+
lang_config = SUPPORTED_LANGUAGES[lang]
|
10
|
+
lang_config && lang_config[:result_block_type] == "ruby"
|
11
|
+
end
|
12
|
+
|
13
|
+
def mermaid_style_result?(lang)
|
14
|
+
lang_config = SUPPORTED_LANGUAGES[lang]
|
15
|
+
lang_config && lang_config[:result_handling] == :mermaid_svg
|
16
|
+
end
|
17
|
+
|
18
|
+
def result_block_header(lang)
|
19
|
+
ruby_style_result?(lang) ? "```ruby RESULT\n" : "```RESULT\n"
|
20
|
+
end
|
21
|
+
|
22
|
+
def result_block_regex(lang)
|
23
|
+
if mermaid_style_result?(lang)
|
24
|
+
# For mermaid, look for existing image tags with .svg extension
|
25
|
+
/^!\[.*\]\(.*\.svg\)$/i
|
26
|
+
elsif ruby_style_result?(lang)
|
27
|
+
/^```ruby\s+RESULT$/i
|
28
|
+
else
|
29
|
+
/^```RESULT$/i
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def add_result_block(result_output, blank_line_before_new_result)
|
34
|
+
if mermaid_style_result?(@current_block_lang)
|
35
|
+
# For mermaid, add the image tag directly without a result block
|
36
|
+
@output_lines << "\n" if blank_line_before_new_result.nil?
|
37
|
+
@output_lines << result_output
|
38
|
+
@output_lines << "\n" unless result_output.empty? || result_output.end_with?("\n")
|
39
|
+
@output_lines << "\n"
|
40
|
+
else
|
41
|
+
@output_lines << "\n" if blank_line_before_new_result.nil?
|
42
|
+
@output_lines << result_block_header(@current_block_lang)
|
43
|
+
@output_lines << result_output
|
44
|
+
@output_lines << "\n" unless result_output.empty? || result_output.end_with?("\n")
|
45
|
+
@output_lines << "```\n\n"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
|
50
|
+
def handle_inside_result_block(current_line, file_enum)
|
51
|
+
@output_lines << current_line
|
52
|
+
if is_block_end?(current_line)
|
53
|
+
@state = :outside_code_block
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def handle_existing_ruby_result_block(current_line, file_enum)
|
58
|
+
warn "Found existing '```ruby RESULT' block, passing through."
|
59
|
+
@output_lines << current_line
|
60
|
+
@state = :inside_result_block
|
61
|
+
end
|
62
|
+
|
63
|
+
|
64
|
+
def execute_and_add_result(blank_line_before_new_result)
|
65
|
+
warn "Skipping empty code block for language '#{@current_block_lang}'." && return unless has_content?(@current_code_content)
|
66
|
+
|
67
|
+
@output_lines << blank_line_before_new_result if blank_line_before_new_result
|
68
|
+
|
69
|
+
result_output = CodeExecutor.execute(@current_code_content, @current_block_lang, @temp_dir, @input_file_path, @current_block_explain, @current_block_flamegraph)
|
70
|
+
|
71
|
+
# Check if result contains a Dalibo link for psql explain queries
|
72
|
+
dalibo_link, result_after_dalibo = extract_dalibo_link(result_output)
|
73
|
+
|
74
|
+
# Check if result contains a flamegraph link for psql flamegraph queries
|
75
|
+
flamegraph_link, clean_result = extract_flamegraph_link(result_after_dalibo)
|
76
|
+
|
77
|
+
# Add the result block only if result=true (default)
|
78
|
+
if @current_block_result
|
79
|
+
add_result_block(clean_result || result_after_dalibo, blank_line_before_new_result)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Always add Dalibo link if it exists, even when result=false
|
83
|
+
if dalibo_link
|
84
|
+
# Add appropriate spacing based on whether result block was shown
|
85
|
+
if @current_block_result
|
86
|
+
@output_lines << "#{dalibo_link}\n\n"
|
87
|
+
else
|
88
|
+
@output_lines << "\n#{dalibo_link}\n\n"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Always add flamegraph link if it exists, even when result=false
|
93
|
+
if flamegraph_link
|
94
|
+
# Add appropriate spacing based on whether result block was shown
|
95
|
+
if @current_block_result || dalibo_link
|
96
|
+
@output_lines << "#{flamegraph_link}\n\n"
|
97
|
+
else
|
98
|
+
@output_lines << "\n#{flamegraph_link}\n\n"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def skip_and_pass_through_result(lines_to_pass_through, file_enum, decision = nil)
|
104
|
+
# Handle run=false case where there are no lines to pass through
|
105
|
+
if lines_to_pass_through.empty?
|
106
|
+
warn "Skipping execution due to run=false option."
|
107
|
+
return
|
108
|
+
end
|
109
|
+
|
110
|
+
# Check if this is Dalibo content
|
111
|
+
if decision && decision[:dalibo_content]
|
112
|
+
warn "Found existing Dalibo link for current #{@current_block_lang} block, skipping execution."
|
113
|
+
@output_lines.concat(lines_to_pass_through)
|
114
|
+
# No additional consumption needed for Dalibo links
|
115
|
+
return
|
116
|
+
end
|
117
|
+
|
118
|
+
# Check if this is flamegraph content
|
119
|
+
if decision && decision[:flamegraph_content]
|
120
|
+
warn "Found existing flamegraph for current #{@current_block_lang} block, skipping execution."
|
121
|
+
@output_lines.concat(lines_to_pass_through)
|
122
|
+
# No additional consumption needed for flamegraph links
|
123
|
+
return
|
124
|
+
end
|
125
|
+
|
126
|
+
if mermaid_style_result?(@current_block_lang)
|
127
|
+
warn "Found existing mermaid SVG image for current #{@current_block_lang} block, skipping execution."
|
128
|
+
@output_lines.concat(lines_to_pass_through)
|
129
|
+
# For mermaid, no additional consumption needed since it's just an image line
|
130
|
+
else
|
131
|
+
lang_specific_result_type = ruby_style_result?(@current_block_lang) ? "```ruby RESULT" : "```RESULT"
|
132
|
+
warn "Found existing '#{lang_specific_result_type}' block for current #{@current_block_lang} block, skipping execution."
|
133
|
+
@output_lines.concat(lines_to_pass_through)
|
134
|
+
consume_result_block_content(file_enum)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def consume_result_block_content(file_enum)
|
139
|
+
consume_block_lines(file_enum) do |line|
|
140
|
+
@output_lines << line
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def consume_existing_result_block(file_enum, consumed_lines)
|
145
|
+
return if mermaid_style_result?(@current_block_lang)
|
146
|
+
|
147
|
+
consume_block_lines(file_enum) do |line|
|
148
|
+
consumed_lines << line
|
149
|
+
end
|
150
|
+
|
151
|
+
consume_dalibo_link_if_present(file_enum, consumed_lines)
|
152
|
+
consume_flamegraph_link_if_present(file_enum, consumed_lines)
|
153
|
+
end
|
154
|
+
|
155
|
+
|
156
|
+
def consume_block_lines(file_enum)
|
157
|
+
begin
|
158
|
+
loop do
|
159
|
+
result_block_line = file_enum.next
|
160
|
+
yield result_block_line
|
161
|
+
break if is_block_end?(result_block_line)
|
162
|
+
end
|
163
|
+
rescue StopIteration
|
164
|
+
warn "Warning: End of file reached while consuming result block."
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
data/markdown-run-sample.md
CHANGED
@@ -76,3 +76,12 @@ Page cache spills: 0
|
|
76
76
|
Schema Heap Usage: 0 bytes
|
77
77
|
Statement Heap/Lookaside Usage: 0 bytes
|
78
78
|
```
|
79
|
+
|
80
|
+
```psql rerun flamegraph
|
81
|
+
select 42 as answer;
|
82
|
+
```
|
83
|
+
|
84
|
+
```RESULT
|
85
|
+

|
86
|
+
```
|
87
|
+
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: markdown-run
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.12
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Aurélien Bottazini
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-06-
|
11
|
+
date: 2025-06-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rcodetools
|
@@ -24,6 +24,20 @@ dependencies:
|
|
24
24
|
- - '='
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: 0.8.5
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: ostruct
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.6.1
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.6.1
|
27
41
|
- !ruby/object:Gem::Dependency
|
28
42
|
name: minitest
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -84,16 +98,21 @@ files:
|
|
84
98
|
- README.md
|
85
99
|
- Rakefile
|
86
100
|
- exe/markdown-run
|
101
|
+
- lib/code_block_helper.rb
|
87
102
|
- lib/code_block_parser.rb
|
88
103
|
- lib/code_executor.rb
|
104
|
+
- lib/dalibo_helper.rb
|
89
105
|
- lib/enum_helper.rb
|
90
106
|
- lib/execution_decider.rb
|
107
|
+
- lib/flamegraph_helper.rb
|
91
108
|
- lib/frontmatter_parser.rb
|
92
109
|
- lib/language_configs.rb
|
93
110
|
- lib/markdown/run/version.rb
|
94
111
|
- lib/markdown_file_writer.rb
|
95
112
|
- lib/markdown_processor.rb
|
96
113
|
- lib/markdown_run.rb
|
114
|
+
- lib/pg_flamegraph_svg.rb
|
115
|
+
- lib/result_helper.rb
|
97
116
|
- markdown-run-sample.md
|
98
117
|
homepage: https://github.com/aurelienbottazini/markdown-run
|
99
118
|
licenses:
|