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.
@@ -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('&', '&amp;')
216
+ .gsub('<', '&lt;')
217
+ .gsub('>', '&gt;')
218
+ .gsub('"', '&quot;')
219
+ .gsub("'", '&#39;')
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
@@ -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
+ ![PostgreSQL Query Flamegraph](pg-flamegraph-20250604-222931.svg)
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.10
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-03 00:00:00.000000000 Z
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: