markdown-run 0.1.11 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e078472cf91253d16b3341865954ead4ce1d6539d509c5ed6d54302c5a3147d
4
- data.tar.gz: 76f40572409ed01a1797c4902896b783d3a0403783031fca8c8dff04a1499acf
3
+ metadata.gz: 2764390f72c90c38bd4862f78db857bd5585d13e59f1a2c87a3a9c67c2f66529
4
+ data.tar.gz: 95caace78ee0db7d12ac3a6a7a5a9abcddf32c1e946ff3d9cf71beed12c89a91
5
5
  SHA512:
6
- metadata.gz: ea3ecacb94cbe3e52dacb9fdb4ea447790990cee631fd890f5389d483157d7e16621eda6dba68dddf13d1e1f5f499b8bc2ac3952e2ca1752f133f5aa496403b1
7
- data.tar.gz: 8ae64565dc336f5158a877c54a02520bc6a0e6923c6bbdd705c2cff8c848ff832f0a0683f30c2e86df883a356d89e60269cebb219d9a9732212fc5edfd27fea6
6
+ metadata.gz: 90aea8dd4f03bc75969643bedd914ed2a8a8a1b45adf777d51fdfabb60deefd5e36cd1b73ac37225848b4663e12872a69c2ba4c3624029ac4c81c333a8eb6a8c
7
+ data.tar.gz: 7dfc12cda3abca94774e013904e2d9853713dce9c8b4059d6ca6bd90319ddff4f9f0d826c230d508f2438fdce57626f7868c1b94c9756ec3a122e0645be2121c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.12] - 2025-06-04
4
+
5
+ - Added `flamegraph` option for psql code blocks to generate PostgreSQL query execution plan flamegraphs as SVG images
6
+ - PostgreSQL flamegraphs provide interactive, color-coded visualization of query performance with hover tooltips
7
+ - Flamegraph SVG files follow same directory structure as mermaid diagrams (organized by markdown file basename)
8
+ - Added flamegraph option support to frontmatter defaults and language-specific configurations
9
+
3
10
  ## [0.1.11] - 2025-06-04
4
11
 
5
12
  - options are customizable with the yaml frontmatter
data/README.md CHANGED
@@ -59,6 +59,7 @@ example vscode keybinding
59
59
  - `rerun=true` or `rerun=false` to control whether a code block should be re-executed if a result block already exists. `rerun=false` is the default if not specified
60
60
  - `result=true` or `result=false` to control whether the result block should be displayed after execution. `result=true` is the default if not specified. When `result=false`, the code still executes but the result block is hidden
61
61
  - `explain=true` or `explain=false` for psql code blocks to generate query execution plans with Dalibo visualization links. `explain=false` is the default if not specified
62
+ - `flamegraph=true` or `flamegraph=false` for psql code blocks to generate PostgreSQL query execution plan flamegraphs as SVG images. `flamegraph=false` is the default if not specified
62
63
 
63
64
  Options can be combined. If `run=false` is specified, the code block will not execute regardless of the `rerun` setting. The `result` option only affects display of the result block, not code execution.
64
65
 
@@ -70,6 +71,7 @@ Options can also be specified using standalone syntax without explicit `=true`:
70
71
  - `rerun` is equivalent to `rerun=true`
71
72
  - `result` is equivalent to `result=true`
72
73
  - `explain` is equivalent to `explain=true`
74
+ - `flamegraph` is equivalent to `flamegraph=true`
73
75
 
74
76
  Explicit assignments (e.g., `run=false`) take precedence over standalone options.
75
77
 
@@ -99,11 +101,57 @@ SELECT * FROM users WHERE id = 1;
99
101
  EXPLAIN (ANALYZE) SELECT * FROM large_table;
100
102
  ```
101
103
 
104
+ ```psql flamegraph
105
+ SELECT u.name, COUNT(o.id) as order_count
106
+ FROM users u
107
+ LEFT JOIN orders o ON u.id = o.user_id
108
+ WHERE u.created_at > '2024-01-01'
109
+ GROUP BY u.id, u.name
110
+ ORDER BY order_count DESC
111
+ LIMIT 10;
112
+ ```
113
+
114
+ ```psql flamegraph=true result=false
115
+ -- This will generate a flamegraph but hide the JSON result block
116
+ SELECT * FROM complex_query_with_joins;
117
+ ```
118
+
102
119
  ```psql result=false explain
103
120
  SELECT * FROM large_table;
104
121
  -- This will execute the explain query and show the Dalibo link but hide the result block
105
122
  ```
106
123
 
124
+ ### PostgreSQL Flamegraphs
125
+
126
+ PostgreSQL flamegraph blocks generate interactive SVG flamegraphs from query execution plans:
127
+
128
+ ```psql flamegraph
129
+ SELECT users.*, orders.total
130
+ FROM users
131
+ JOIN orders ON users.id = orders.user_id
132
+ WHERE users.created_at > '2024-01-01';
133
+ ```
134
+
135
+ This generates:
136
+
137
+ - An SVG flamegraph file in a directory named after the markdown file
138
+ - Filename format: `my-document-flamegraph-20250118-143022-a1b2c3.svg`
139
+ - Embedded image tag: `![PostgreSQL Query Flamegraph](my-document/my-document-flamegraph-20250118-143022-a1b2c3.svg)`
140
+
141
+ **Flamegraph features:**
142
+
143
+ - **Interactive**: Hover over rectangles to see detailed timing information
144
+ - **Color-coded**: Different colors for operation types (red=seq scans, green=index scans, blue=joins, etc.)
145
+ - **Hierarchical**: Shows query plan structure visually
146
+ - **Performance insights**: Width represents execution time, making bottlenecks immediately visible
147
+
148
+ **What flamegraphs help identify:**
149
+
150
+ - Slow operations (widest rectangles)
151
+ - Query plan structure (nested relationships)
152
+ - Inefficient operations (color-coded by type)
153
+ - Execution time distribution across plan nodes
154
+
107
155
  ### Mermaid diagrams
108
156
 
109
157
  Mermaid blocks generate SVG files and insert markdown image tags:
@@ -163,6 +211,7 @@ markdown-run:
163
211
  - `rerun`: Control whether to re-execute if result exists (default: `false`)
164
212
  - `result`: Control whether to show result blocks (default: `true`)
165
213
  - `explain`: For psql blocks, generate explain plans (default: `false`)
214
+ - `flamegraph`: For psql blocks, generate flamegraph SVGs (default: `false`)
166
215
 
167
216
  **Examples:**
168
217
 
@@ -184,6 +233,14 @@ markdown-run:
184
233
  explain: true
185
234
  ```
186
235
 
236
+ Enable flamegraphs by default for all psql blocks:
237
+
238
+ ```yaml
239
+ markdown-run:
240
+ psql:
241
+ flamegraph: true
242
+ ```
243
+
187
244
  Language-specific settings override global defaults:
188
245
 
189
246
  ```yaml
data/Rakefile CHANGED
@@ -19,4 +19,14 @@ task :flog_detailed do
19
19
  system("flog -d lib/ exe/ test/")
20
20
  end
21
21
 
22
+ desc "Release"
23
+ task :release do
24
+ `gem bump`
25
+ `bundle`
26
+ `git commit --amend`
27
+ `git push`
28
+ `git push --tags`
29
+ `gem release`
30
+ end
31
+
22
32
  task default: :test
@@ -10,6 +10,7 @@ module CodeBlockHelper
10
10
  @current_block_rerun = false
11
11
  @current_block_run = true
12
12
  @current_block_explain = false
13
+ @current_block_flamegraph = false
13
14
  @current_block_result = true
14
15
  end
15
16
 
@@ -20,6 +21,7 @@ module CodeBlockHelper
20
21
  @current_block_rerun = @code_block_parser.parse_rerun_option(options_string, @current_block_lang)
21
22
  @current_block_run = @code_block_parser.parse_run_option(options_string, @current_block_lang)
22
23
  @current_block_explain = @code_block_parser.parse_explain_option(options_string, @current_block_lang)
24
+ @current_block_flamegraph = @code_block_parser.parse_flamegraph_option(options_string, @current_block_lang)
23
25
  @current_block_result = @code_block_parser.parse_result_option(options_string, @current_block_lang)
24
26
  @state = :inside_code_block
25
27
  @current_code_content = ""
@@ -46,7 +48,7 @@ module CodeBlockHelper
46
48
  end
47
49
 
48
50
  def decide_execution(file_enum)
49
- decider = ExecutionDecider.new(@current_block_run, @current_block_rerun, @current_block_lang, @current_block_explain, @current_block_result)
51
+ decider = ExecutionDecider.new(@current_block_run, @current_block_rerun, @current_block_lang, @current_block_explain, @current_block_flamegraph, @current_block_result)
50
52
  decision = decider.decide(file_enum, method(:result_block_regex))
51
53
 
52
54
  # Handle the consume_existing flag for rerun scenarios
@@ -49,6 +49,11 @@ class CodeBlockParser
49
49
  parse_boolean_option(options_string, "explain", default_value)
50
50
  end
51
51
 
52
+ def parse_flamegraph_option(options_string, language = nil)
53
+ default_value = @frontmatter_parser.get_default_value("flamegraph", language, false)
54
+ parse_boolean_option(options_string, "flamegraph", default_value)
55
+ end
56
+
52
57
  def parse_result_option(options_string, language = nil)
53
58
  default_value = @frontmatter_parser.get_default_value("result", language, true)
54
59
  parse_boolean_option(options_string, "result", default_value)
data/lib/code_executor.rb CHANGED
@@ -3,11 +3,11 @@ require "open3"
3
3
  require_relative "language_configs"
4
4
 
5
5
  class CodeExecutor
6
- def self.execute(code_content, lang, temp_dir, input_file_path = nil, explain = false)
7
- new.execute(code_content, lang, temp_dir, input_file_path, explain)
6
+ def self.execute(code_content, lang, temp_dir, input_file_path = nil, explain = false, flamegraph = false)
7
+ new.execute(code_content, lang, temp_dir, input_file_path, explain, flamegraph)
8
8
  end
9
9
 
10
- def execute(code_content, lang, temp_dir, input_file_path = nil, explain = false)
10
+ def execute(code_content, lang, temp_dir, input_file_path = nil, explain = false, flamegraph = false)
11
11
  lang_key = lang.downcase
12
12
  lang_config = SUPPORTED_LANGUAGES[lang_key]
13
13
 
@@ -15,14 +15,12 @@ class CodeExecutor
15
15
 
16
16
  warn "Executing #{lang_key} code block..."
17
17
 
18
- result = execute_with_config(code_content, lang_config, temp_dir, lang_key, input_file_path, explain)
19
- process_execution_result(result, lang_config, lang_key, explain)
18
+ result = execute_with_config(code_content, lang_config, temp_dir, lang_key, input_file_path, explain, flamegraph)
19
+ process_execution_result(result, lang_config, lang_key, explain, flamegraph)
20
20
  end
21
21
 
22
22
  private
23
23
 
24
-
25
-
26
24
  def stderr_has_content?(stderr_output)
27
25
  stderr_output && !stderr_output.strip.empty?
28
26
  end
@@ -32,23 +30,23 @@ class CodeExecutor
32
30
  "ERROR: Unsupported language: #{lang}"
33
31
  end
34
32
 
35
- def execute_with_config(code_content, lang_config, temp_dir, lang_key, input_file_path = nil, explain = false)
33
+ def execute_with_config(code_content, lang_config, temp_dir, lang_key, input_file_path = nil, explain = false, flamegraph = false)
36
34
  cmd_lambda = lang_config[:command]
37
35
  temp_file_suffix = lang_config[:temp_file_suffix]
38
36
 
39
37
  if temp_file_suffix
40
- execute_with_temp_file(code_content, cmd_lambda, temp_file_suffix, temp_dir, lang_key, input_file_path, explain)
38
+ execute_with_temp_file(code_content, cmd_lambda, temp_file_suffix, temp_dir, lang_key, input_file_path, explain, flamegraph)
41
39
  else
42
- execute_direct_command(code_content, cmd_lambda, explain)
40
+ execute_direct_command(code_content, cmd_lambda, input_file_path, explain, flamegraph)
43
41
  end
44
42
  end
45
43
 
46
- def execute_with_temp_file(code_content, cmd_lambda, temp_file_suffix, temp_dir, lang_key, input_file_path = nil, explain = false)
44
+ def execute_with_temp_file(code_content, cmd_lambda, temp_file_suffix, temp_dir, lang_key, input_file_path = nil, explain = false, flamegraph = false)
47
45
  result = nil
48
46
  Tempfile.create([lang_key, temp_file_suffix], temp_dir) do |temp_file|
49
47
  temp_file.write(code_content)
50
48
  temp_file.close
51
- command_to_run, exec_options = cmd_lambda.call(code_content, temp_file.path, input_file_path, explain)
49
+ command_to_run, exec_options = cmd_lambda.call(code_content, temp_file.path, input_file_path, explain, flamegraph)
52
50
 
53
51
  # Extract output_path if present (for mermaid)
54
52
  output_path = exec_options.delete(:output_path) if exec_options.is_a?(Hash)
@@ -58,27 +56,35 @@ class CodeExecutor
58
56
  stdout: captured_stdout,
59
57
  stderr: captured_stderr,
60
58
  status: captured_status_obj,
61
- output_path: output_path # For mermaid SVG output
59
+ output_path: output_path, # For mermaid SVG output
60
+ input_file_path: input_file_path # Pass through for flamegraph generation
62
61
  }
63
62
  end
64
63
  result
65
64
  end
66
65
 
67
- def execute_direct_command(code_content, cmd_lambda, explain = false)
68
- command_to_run, exec_options = cmd_lambda.call(code_content, nil, nil, explain)
66
+ def execute_direct_command(code_content, cmd_lambda, input_file_path = nil, explain = false, flamegraph = false)
67
+ command_to_run, exec_options = cmd_lambda.call(code_content, nil, input_file_path, explain, flamegraph)
69
68
  captured_stdout, captured_stderr, captured_status_obj = Open3.capture3(command_to_run, **exec_options)
70
- { stdout: captured_stdout, stderr: captured_stderr, status: captured_status_obj }
69
+ { stdout: captured_stdout, stderr: captured_stderr, status: captured_status_obj, input_file_path: input_file_path }
71
70
  end
72
71
 
73
- def process_execution_result(result, lang_config, lang_key, explain = false)
72
+ def process_execution_result(result, lang_config, lang_key, explain = false, flamegraph = false)
74
73
  exit_status, result_output, stderr_output = format_captured_output(result, lang_config)
75
74
 
76
75
  if exit_status != 0
77
76
  result_output = add_error_to_output(exit_status, lang_config, lang_key, result_output, stderr_output)
78
77
  elsif lang_config && lang_config[:result_handling] == :mermaid_svg
79
78
  result_output = handle_mermaid_svg_result(result, lang_key)
80
- elsif explain && lang_key == "psql"
81
- result_output = handle_psql_explain_result(result_output)
79
+ else
80
+ # Handle psql explain and flamegraph processing (both can be enabled)
81
+ if explain && lang_key == "psql"
82
+ result_output = handle_psql_explain_result(result_output)
83
+ end
84
+
85
+ if flamegraph && lang_key == "psql"
86
+ result_output = handle_psql_flamegraph_result(result_output, result[:input_file_path])
87
+ end
82
88
  end
83
89
 
84
90
  result_output
@@ -169,6 +175,78 @@ class CodeExecutor
169
175
  end
170
176
  end
171
177
 
178
+ def handle_psql_flamegraph_result(result_output, input_file_path = nil)
179
+ require_relative 'pg_flamegraph_svg'
180
+
181
+ begin
182
+ # Extract clean JSON from result_output (might contain Dalibo link prefix)
183
+ json_text = if result_output.start_with?("DALIBO_LINK:")
184
+ # Extract the JSON part after the Dalibo link line
185
+ lines = result_output.split("\n", 2)
186
+ lines[1] || ""
187
+ else
188
+ result_output.strip
189
+ end
190
+
191
+ # Parse the EXPLAIN JSON output
192
+ json_data = JSON.parse(json_text)
193
+
194
+ # Generate SVG flamegraph
195
+ flamegraph_generator = PostgreSQLFlameGraphSVG.new(JSON.generate(json_data))
196
+ svg_content = flamegraph_generator.generate_svg
197
+
198
+ # Save SVG file following same pattern as mermaid
199
+ if input_file_path
200
+ # Extract markdown file basename without extension
201
+ md_basename = File.basename(input_file_path, ".*")
202
+
203
+ # Create directory named after the markdown file
204
+ output_dir = File.join(File.dirname(input_file_path), md_basename)
205
+ Dir.mkdir(output_dir) unless Dir.exist?(output_dir)
206
+
207
+ # Generate unique filename with markdown basename prefix
208
+ timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
209
+ random_suffix = SecureRandom.hex(6)
210
+ svg_filename = "#{md_basename}-flamegraph-#{timestamp}-#{random_suffix}.svg"
211
+ output_path = File.join(output_dir, svg_filename)
212
+ else
213
+ # Fallback to simple naming
214
+ timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
215
+ output_path = "pg-flamegraph-#{timestamp}.svg"
216
+ end
217
+
218
+ # Write SVG file
219
+ File.write(output_path, svg_content)
220
+
221
+ # Generate relative path for markdown
222
+ if input_file_path
223
+ relative_path = "#{File.basename(output_dir)}/#{File.basename(output_path)}"
224
+ else
225
+ relative_path = File.basename(output_path)
226
+ end
227
+
228
+ warn "Generated PostgreSQL flamegraph: #{relative_path}"
229
+
230
+ # Return a special format that the markdown processor can parse
231
+ # Preserve any existing Dalibo link prefix
232
+ if result_output.start_with?("DALIBO_LINK:")
233
+ lines = result_output.split("\n", 2)
234
+ dalibo_part = lines[0]
235
+ json_part = lines[1] || ""
236
+ "#{dalibo_part}\nFLAMEGRAPH_LINK:#{relative_path}\n#{json_part}"
237
+ else
238
+ "FLAMEGRAPH_LINK:#{relative_path}\n#{json_text}"
239
+ end
240
+
241
+ rescue JSON::ParserError => e
242
+ warn "Error parsing EXPLAIN JSON: #{e.message}"
243
+ result_output
244
+ rescue => e
245
+ warn "Error generating flamegraph: #{e.message}"
246
+ result_output
247
+ end
248
+ end
249
+
172
250
  private
173
251
 
174
252
  def submit_plan_to_dalibo(plan_json)
data/lib/dalibo_helper.rb CHANGED
@@ -9,7 +9,7 @@ module DaliboHelper
9
9
  lines = result_output.split("\n", 2)
10
10
  dalibo_url = lines[0].sub(DALIBO_LINK_PREFIX, "")
11
11
  clean_result = lines[1] || ""
12
- dalibo_link = "**Dalibo Visualization:** [View Query Plan](#{dalibo_url})"
12
+ dalibo_link = "[Dalibo](#{dalibo_url})"
13
13
  [dalibo_link, clean_result]
14
14
  else
15
15
  [nil, result_output]
@@ -25,7 +25,7 @@ module DaliboHelper
25
25
 
26
26
  if is_blank_line?(next_line)
27
27
  consumed_lines << file_enum.next
28
- elsif next_line&.start_with?("**Dalibo Visualization:**")
28
+ elsif next_line&.start_with?("[Dalibo]")
29
29
  consumed_lines << file_enum.next
30
30
  else
31
31
  # Hit something that's not a blank line or Dalibo link, stop consuming
@@ -3,11 +3,12 @@ require_relative "enum_helper"
3
3
  class ExecutionDecider
4
4
  include EnumHelper
5
5
 
6
- def initialize(current_block_run, current_block_rerun, current_block_lang, current_block_explain = false, current_block_result = true)
6
+ def initialize(current_block_run, current_block_rerun, current_block_lang, current_block_explain = false, current_block_flamegraph = false, current_block_result = true)
7
7
  @current_block_run = current_block_run
8
8
  @current_block_rerun = current_block_rerun
9
9
  @current_block_lang = current_block_lang
10
10
  @current_block_explain = current_block_explain
11
+ @current_block_flamegraph = current_block_flamegraph
11
12
  @current_block_result = current_block_result
12
13
  end
13
14
 
@@ -21,8 +22,10 @@ class ExecutionDecider
21
22
  handle_immediate_result_block(file_enum)
22
23
  elsif is_blank_line?(peek1)
23
24
  handle_blank_line_scenario(file_enum, expected_header_regex)
24
- elsif @current_block_explain && is_dalibo_link?(peek1)
25
+ elsif (@current_block_explain || @current_block_flamegraph) && is_dalibo_link?(peek1)
25
26
  handle_immediate_dalibo_link(file_enum)
27
+ elsif @current_block_flamegraph && is_flamegraph_link?(peek1)
28
+ handle_immediate_flamegraph_link(file_enum)
26
29
  else
27
30
  execute_without_existing_result
28
31
  end
@@ -48,11 +51,11 @@ class ExecutionDecider
48
51
 
49
52
  def handle_blank_line_scenario(file_enum, expected_header_regex)
50
53
  consumed_blank_line = file_enum.next
51
-
54
+
52
55
  # Look ahead past multiple blank lines to find actual content
53
56
  peek2 = peek_next_line(file_enum)
54
57
  additional_blanks = []
55
-
58
+
56
59
  # Consume consecutive blank lines
57
60
  while is_blank_line?(peek2)
58
61
  additional_blanks << file_enum.next
@@ -61,8 +64,10 @@ class ExecutionDecider
61
64
 
62
65
  if line_matches_pattern?(peek2, expected_header_regex)
63
66
  handle_result_after_blank_lines(file_enum, consumed_blank_line, additional_blanks)
64
- elsif @current_block_explain && is_dalibo_link?(peek2)
67
+ elsif (@current_block_explain || @current_block_flamegraph) && is_dalibo_link?(peek2)
65
68
  handle_dalibo_after_blank_lines(file_enum, consumed_blank_line, additional_blanks)
69
+ elsif @current_block_flamegraph && is_flamegraph_link?(peek2)
70
+ handle_flamegraph_after_blank_lines(file_enum, consumed_blank_line, additional_blanks)
66
71
  else
67
72
  execute_with_blank_lines(consumed_blank_line, additional_blanks)
68
73
  end
@@ -194,7 +199,7 @@ class ExecutionDecider
194
199
  # Consume all consecutive Dalibo links and blank lines
195
200
  loop do
196
201
  next_line = peek_next_line(file_enum)
197
-
202
+
198
203
  if is_blank_line?(next_line) || is_dalibo_link?(next_line)
199
204
  consumed_line = file_enum.next
200
205
  consumed_lines << consumed_line
@@ -205,7 +210,7 @@ class ExecutionDecider
205
210
  end
206
211
 
207
212
  def is_dalibo_link?(line)
208
- line&.start_with?("**Dalibo Visualization:**")
213
+ line&.start_with?("[Dalibo]")
209
214
  end
210
215
 
211
216
  def line_matches_pattern?(line, pattern)
@@ -217,9 +222,78 @@ class ExecutionDecider
217
222
  end
218
223
 
219
224
  def should_auto_replace_dalibo_link?
220
- # Auto-replace Dalibo links when using explain with result=false
225
+ # Auto-replace Dalibo links when using explain or flamegraph with result=false
221
226
  # This makes sense because with result=false, there's only a Dalibo link,
222
227
  # so it should be updated on each run
223
- @current_block_explain && !@current_block_result
228
+ (@current_block_explain || @current_block_flamegraph) && !@current_block_result
229
+ end
230
+
231
+ def is_flamegraph_link?(line)
232
+ line&.start_with?("![PostgreSQL Query Flamegraph]")
233
+ end
234
+
235
+ def handle_flamegraph_after_blank_lines(file_enum, consumed_blank_line, additional_blanks)
236
+ # For flamegraph result=false, always replace existing flamegraph links
237
+ # For flamegraph result=true, follow normal rerun logic
238
+ if should_auto_replace_flamegraph_link? || @current_block_rerun
239
+ execute_with_consumed_flamegraph_and_blanks(file_enum, consumed_blank_line, additional_blanks)
240
+ else
241
+ skip_with_blanks_and_flamegraph(file_enum, consumed_blank_line, additional_blanks)
242
+ end
243
+ end
244
+
245
+ def handle_immediate_flamegraph_link(file_enum)
246
+ # For flamegraph result=false, always replace existing flamegraph links
247
+ # For flamegraph result=true, follow normal rerun logic
248
+ if should_auto_replace_flamegraph_link? || @current_block_rerun
249
+ execute_with_consumed_flamegraph(file_enum)
250
+ else
251
+ skip_with_existing_flamegraph(file_enum)
252
+ end
253
+ end
254
+
255
+ def execute_with_consumed_flamegraph(file_enum)
256
+ consumed_lines = []
257
+ consume_flamegraph_links(file_enum, consumed_lines)
258
+ { execute: true, consumed_lines: consumed_lines, consume_existing_flamegraph: true }
259
+ end
260
+
261
+ def skip_with_existing_flamegraph(file_enum)
262
+ consumed_lines = []
263
+ consume_flamegraph_links(file_enum, consumed_lines)
264
+ { execute: false, lines_to_pass_through: consumed_lines, flamegraph_content: true }
265
+ end
266
+
267
+ def execute_with_consumed_flamegraph_and_blanks(file_enum, consumed_blank_line, additional_blanks)
268
+ consumed_lines = [consumed_blank_line] + additional_blanks
269
+ consume_flamegraph_links(file_enum, consumed_lines)
270
+ { execute: true, consumed_lines: consumed_lines, blank_line: consumed_blank_line, consume_existing_flamegraph: true }
271
+ end
272
+
273
+ def skip_with_blanks_and_flamegraph(file_enum, consumed_blank_line, additional_blanks)
274
+ consumed_lines = [consumed_blank_line] + additional_blanks
275
+ consume_flamegraph_links(file_enum, consumed_lines)
276
+ { execute: false, lines_to_pass_through: consumed_lines, flamegraph_content: true }
277
+ end
278
+
279
+ def consume_flamegraph_links(file_enum, consumed_lines)
280
+ # Consume all consecutive flamegraph links and blank lines
281
+ loop do
282
+ next_line = peek_next_line(file_enum)
283
+
284
+ if is_blank_line?(next_line) || is_flamegraph_link?(next_line)
285
+ consumed_line = file_enum.next
286
+ consumed_lines << consumed_line
287
+ else
288
+ break
289
+ end
290
+ end
291
+ end
292
+
293
+ def should_auto_replace_flamegraph_link?
294
+ # Auto-replace flamegraph links when using flamegraph with result=false
295
+ # This makes sense because with result=false, there's only a flamegraph link,
296
+ # so it should be updated on each run
297
+ @current_block_flamegraph && !@current_block_result
224
298
  end
225
299
  end
@@ -0,0 +1,39 @@
1
+ module FlamegraphHelper
2
+ private
3
+
4
+ FLAMEGRAPH_LINK_PREFIX = "FLAMEGRAPH_LINK:"
5
+
6
+ def extract_flamegraph_link(result_output)
7
+ # Check if the result contains a flamegraph link marker
8
+ if result_output.start_with?(FLAMEGRAPH_LINK_PREFIX)
9
+ lines = result_output.split("\n", 2)
10
+ flamegraph_path = lines[0].sub(FLAMEGRAPH_LINK_PREFIX, "")
11
+ clean_result = lines[1] || ""
12
+ flamegraph_link = "![PostgreSQL Query Flamegraph](#{flamegraph_path})"
13
+ [flamegraph_link, clean_result]
14
+ else
15
+ [nil, result_output]
16
+ end
17
+ end
18
+
19
+ def consume_flamegraph_link_if_present(file_enum, consumed_lines)
20
+ # Look ahead to see if there are flamegraph links after the result block
21
+ begin
22
+ # Keep consuming blank lines and flamegraph links until we hit something else
23
+ loop do
24
+ next_line = peek_next_line(file_enum)
25
+
26
+ if is_blank_line?(next_line)
27
+ consumed_lines << file_enum.next
28
+ elsif next_line&.start_with?("![PostgreSQL Query Flamegraph]")
29
+ consumed_lines << file_enum.next
30
+ else
31
+ # Hit something that's not a blank line or flamegraph link, stop consuming
32
+ break
33
+ end
34
+ end
35
+ rescue StopIteration
36
+ # End of file reached, nothing more to consume
37
+ end
38
+ end
39
+ end
@@ -1,7 +1,7 @@
1
1
  require 'securerandom'
2
2
 
3
3
  JS_CONFIG = {
4
- command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
4
+ command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false, flamegraph = false) {
5
5
  # Check if bun is available
6
6
  bun_exists = system("command -v bun > /dev/null 2>&1")
7
7
  if bun_exists
@@ -16,20 +16,20 @@ JS_CONFIG = {
16
16
  }.freeze
17
17
 
18
18
  SQLITE_CONFIG = {
19
- command: ->(code_content, temp_file_path, input_file_path = nil, explain = false) { [ "sqlite3 #{temp_file_path}", { stdin_data: code_content } ] },
19
+ command: ->(code_content, temp_file_path, input_file_path = nil, explain = false, flamegraph = false) { [ "sqlite3 #{temp_file_path}", { stdin_data: code_content } ] },
20
20
  temp_file_suffix: ".db" # Temp file is the database
21
21
  }.freeze
22
22
 
23
23
  SUPPORTED_LANGUAGES = {
24
24
  "psql" => {
25
- command: ->(code_content, _temp_file_path, input_file_path = nil, explain = false) {
25
+ command: ->(code_content, _temp_file_path, input_file_path = nil, explain = false, flamegraph = false) {
26
26
  psql_exists = system("command -v psql > /dev/null 2>&1")
27
27
  unless psql_exists
28
28
  abort "Error: psql command not found. Please install PostgreSQL or ensure psql is in your PATH."
29
29
  end
30
30
 
31
- # Modify the SQL query if explain option is enabled
32
- if explain
31
+ # Modify the SQL query if explain or flamegraph option is enabled
32
+ if explain || flamegraph
33
33
  # Wrap the query with EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
34
34
  # Remove any trailing semicolons and whitespace, then add our EXPLAIN wrapper
35
35
  clean_query = code_content.strip.gsub(/;\s*$/, '')
@@ -41,7 +41,7 @@ SUPPORTED_LANGUAGES = {
41
41
  }
42
42
  },
43
43
  "ruby" => {
44
- command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
44
+ command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false, flamegraph = false) {
45
45
  xmpfilter_exists = system("command -v xmpfilter > /dev/null 2>&1")
46
46
  unless xmpfilter_exists
47
47
  abort "Error: xmpfilter command not found. Please install xmpfilter or ensure it is in your PATH."
@@ -57,7 +57,7 @@ SUPPORTED_LANGUAGES = {
57
57
  "sqlite" => SQLITE_CONFIG,
58
58
  "sqlite3" => SQLITE_CONFIG, # Alias for sqlite
59
59
  "bash" => {
60
- command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
60
+ command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false, flamegraph = false) {
61
61
  bash_exists = system("command -v bash > /dev/null 2>&1")
62
62
  unless bash_exists
63
63
  abort "Error: bash command not found. Please ensure bash is in your PATH."
@@ -67,7 +67,7 @@ SUPPORTED_LANGUAGES = {
67
67
  temp_file_suffix: ".sh"
68
68
  },
69
69
  "zsh" => {
70
- command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
70
+ command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false, flamegraph = false) {
71
71
  zsh_exists = system("command -v zsh > /dev/null 2>&1")
72
72
  unless zsh_exists
73
73
  abort "Error: zsh command not found. Please ensure zsh is in your PATH."
@@ -77,7 +77,7 @@ SUPPORTED_LANGUAGES = {
77
77
  temp_file_suffix: ".zsh"
78
78
  },
79
79
  "sh" => {
80
- command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
80
+ command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false, flamegraph = false) {
81
81
  sh_exists = system("command -v sh > /dev/null 2>&1")
82
82
  unless sh_exists
83
83
  abort "Error: sh command not found. Please ensure sh is in your PATH."
@@ -87,7 +87,7 @@ SUPPORTED_LANGUAGES = {
87
87
  temp_file_suffix: ".sh"
88
88
  },
89
89
  "mermaid" => {
90
- command: ->(code_content, temp_file_path, input_file_path = nil, explain = false) {
90
+ command: ->(code_content, temp_file_path, input_file_path = nil, explain = false, flamegraph = false) {
91
91
  mmdc_exists = system("command -v mmdc > /dev/null 2>&1")
92
92
  unless mmdc_exists
93
93
  abort "Error: mmdc command not found. Please install @mermaid-js/mermaid-cli: npm install -g @mermaid-js/mermaid-cli"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Markdown
4
4
  module Run
5
- VERSION = "0.1.11"
5
+ VERSION = "0.1.12"
6
6
  end
7
7
  end
@@ -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
data/lib/result_helper.rb CHANGED
@@ -1,4 +1,8 @@
1
+ require_relative "flamegraph_helper"
2
+
1
3
  module ResultHelper
4
+ include FlamegraphHelper
5
+
2
6
  private
3
7
 
4
8
  def ruby_style_result?(lang)
@@ -62,14 +66,17 @@ module ResultHelper
62
66
 
63
67
  @output_lines << blank_line_before_new_result if blank_line_before_new_result
64
68
 
65
- result_output = CodeExecutor.execute(@current_code_content, @current_block_lang, @temp_dir, @input_file_path, @current_block_explain)
69
+ result_output = CodeExecutor.execute(@current_code_content, @current_block_lang, @temp_dir, @input_file_path, @current_block_explain, @current_block_flamegraph)
66
70
 
67
71
  # Check if result contains a Dalibo link for psql explain queries
68
- dalibo_link, clean_result = extract_dalibo_link(result_output)
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)
69
76
 
70
77
  # Add the result block only if result=true (default)
71
78
  if @current_block_result
72
- add_result_block(clean_result || result_output, blank_line_before_new_result)
79
+ add_result_block(clean_result || result_after_dalibo, blank_line_before_new_result)
73
80
  end
74
81
 
75
82
  # Always add Dalibo link if it exists, even when result=false
@@ -81,6 +88,16 @@ module ResultHelper
81
88
  @output_lines << "\n#{dalibo_link}\n\n"
82
89
  end
83
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
84
101
  end
85
102
 
86
103
  def skip_and_pass_through_result(lines_to_pass_through, file_enum, decision = nil)
@@ -98,6 +115,14 @@ module ResultHelper
98
115
  return
99
116
  end
100
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
+
101
126
  if mermaid_style_result?(@current_block_lang)
102
127
  warn "Found existing mermaid SVG image for current #{@current_block_lang} block, skipping execution."
103
128
  @output_lines.concat(lines_to_pass_through)
@@ -124,6 +149,7 @@ module ResultHelper
124
149
  end
125
150
 
126
151
  consume_dalibo_link_if_present(file_enum, consumed_lines)
152
+ consume_flamegraph_link_if_present(file_enum, consumed_lines)
127
153
  end
128
154
 
129
155
 
@@ -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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: markdown-run
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.11
4
+ version: 0.1.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aurélien Bottazini
@@ -104,12 +104,14 @@ files:
104
104
  - lib/dalibo_helper.rb
105
105
  - lib/enum_helper.rb
106
106
  - lib/execution_decider.rb
107
+ - lib/flamegraph_helper.rb
107
108
  - lib/frontmatter_parser.rb
108
109
  - lib/language_configs.rb
109
110
  - lib/markdown/run/version.rb
110
111
  - lib/markdown_file_writer.rb
111
112
  - lib/markdown_processor.rb
112
113
  - lib/markdown_run.rb
114
+ - lib/pg_flamegraph_svg.rb
113
115
  - lib/result_helper.rb
114
116
  - markdown-run-sample.md
115
117
  homepage: https://github.com/aurelienbottazini/markdown-run