markdown-run 0.1.9 → 0.1.10

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: d87607302aa1e1f4b95d1da2b1857f792d34b3d3d7985299db1a6730d7f49b57
4
- data.tar.gz: d10dd3193558ce3c59770bd5b9ba7f73e8a5f3459cea9482deeff7bfc21333a1
3
+ metadata.gz: 1139e0ccb6ed1b5b4ee2cef44cf067c83fc8a92ad750eae2e256725282534b68
4
+ data.tar.gz: 8887f778eaec56e6bdbb84d0a48f8db82fbd52cabbcdea01c48f4cffb5ff410b
5
5
  SHA512:
6
- metadata.gz: 1bbf13073d892d85165b1396a78ec6f274cb75a941fd8dc50f21b4c9636572966c2983f001851286fcb14531eed0a3d2177df42f8d1851e98f62175c5b83c78e
7
- data.tar.gz: 97f9f35734c1440d8b21cc62176a65458bce898cb223368c65af4e0888f4bbd986dc6e403a382f982561dc6ad572f4f9c24c3f2f41d8c0aa6186cc6d5d25f656
6
+ metadata.gz: 6656d86bf179be5a044a95ccbc1a9c11e68642a35ed2ffa99138dc0de653ed4b2e176a9763f08b765d409ec232e4b9eda4779b166c97b7304ed156a48b147c93
7
+ data.tar.gz: 3f8771ece0365e9a1c7073a6d749d41570776b59e9cbe0c38383516cce9cf0261a2ba287aadb30278ee0cf72e859fe4b4cb072fa89294bc3a525700fdec5ff9c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.10] - 2025-06-03
4
+
5
+ - standalone options for codeblocks (run instead of run=true)
6
+ - explain option for psql code blocks with Dalibo visualization links
7
+ - Fixed Dalibo URL generation to properly submit plans via HTTP POST
8
+ - Added result option to control result block visibility (result=false hides result blocks while still executing code)
9
+
3
10
  ## [0.1.9] - 2025-06-02
4
11
 
5
12
  - mermaid codeblocks
data/README.md CHANGED
@@ -57,8 +57,21 @@ example vscode keybinding
57
57
 
58
58
  - `run=true` or `run=false` to control whether a code block should be executed at all. `run=true` is the default if not specified
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
+ - `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
+ - `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
60
62
 
61
- Options can be combined. If `run=false` is specified, the code block will not execute regardless of the `rerun` setting.
63
+ 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
+ ### Standalone Option Syntax
66
+
67
+ Options can also be specified using standalone syntax without explicit `=true`:
68
+
69
+ - `run` is equivalent to `run=true`
70
+ - `rerun` is equivalent to `rerun=true`
71
+ - `result` is equivalent to `result=true`
72
+ - `explain` is equivalent to `explain=true`
73
+
74
+ Explicit assignments (e.g., `run=false`) take precedence over standalone options.
62
75
 
63
76
  Examples:
64
77
 
@@ -66,7 +79,7 @@ Examples:
66
79
  console.log("This will not execute at all");
67
80
  ```
68
81
 
69
- ```js rerun=true
82
+ ```js rerun
70
83
  console.log("This will re-execute even if result exists");
71
84
  ```
72
85
 
@@ -74,6 +87,23 @@ console.log("This will re-execute even if result exists");
74
87
  console.log("This will execute only if no result exists");
75
88
  ```
76
89
 
90
+ ```ruby result=false run
91
+ puts "This executes but the result block is hidden"
92
+ ```
93
+
94
+ ```psql explain
95
+ SELECT * FROM users WHERE id = 1;
96
+ ```
97
+
98
+ ```psql explain=true
99
+ EXPLAIN (ANALYZE) SELECT * FROM large_table;
100
+ ```
101
+
102
+ ```psql result=false explain
103
+ SELECT * FROM large_table;
104
+ -- This will execute the explain query and show the Dalibo link but hide the result block
105
+ ```
106
+
77
107
  ### Mermaid diagrams
78
108
 
79
109
  Mermaid blocks generate SVG files and insert markdown image tags:
@@ -42,6 +42,14 @@ class CodeBlockParser
42
42
  parse_boolean_option(options_string, "rerun", false)
43
43
  end
44
44
 
45
+ def parse_explain_option(options_string)
46
+ parse_boolean_option(options_string, "explain", false)
47
+ end
48
+
49
+ def parse_result_option(options_string)
50
+ parse_boolean_option(options_string, "result", true)
51
+ end
52
+
45
53
  private
46
54
 
47
55
  def resolve_language(lang)
@@ -51,10 +59,19 @@ class CodeBlockParser
51
59
  def parse_boolean_option(options_string, option_name, default_value)
52
60
  return default_value unless options_string
53
61
 
54
- # Match option=true or option=false
55
- match = options_string.match(/#{option_name}\s*=\s*(true|false)/i)
56
- return default_value unless match
62
+ # First, check for explicit option=true/false assignments (highest priority)
63
+ explicit_match = options_string.match(/#{option_name}\s*=\s*(true|false)/i)
64
+ if explicit_match
65
+ return explicit_match[1].downcase == "true"
66
+ end
67
+
68
+ # If no explicit assignment, check for standalone option (e.g., "rerun")
69
+ standalone_match = options_string.match(/\b#{option_name}\b(?!\s*=)/i)
70
+ if standalone_match
71
+ return true
72
+ end
57
73
 
58
- match[1].downcase == "true"
74
+ # If neither found, return default value
75
+ default_value
59
76
  end
60
77
  end
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)
7
- new.execute(code_content, lang, temp_dir, input_file_path)
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)
8
8
  end
9
9
 
10
- def execute(code_content, lang, temp_dir, input_file_path = nil)
10
+ def execute(code_content, lang, temp_dir, input_file_path = nil, explain = false)
11
11
  lang_key = lang.downcase
12
12
  lang_config = SUPPORTED_LANGUAGES[lang_key]
13
13
 
@@ -15,8 +15,8 @@ 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)
19
- process_execution_result(result, lang_config, lang_key)
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)
20
20
  end
21
21
 
22
22
  private
@@ -26,23 +26,23 @@ class CodeExecutor
26
26
  "ERROR: Unsupported language: #{lang}"
27
27
  end
28
28
 
29
- def execute_with_config(code_content, lang_config, temp_dir, lang_key, input_file_path = nil)
29
+ def execute_with_config(code_content, lang_config, temp_dir, lang_key, input_file_path = nil, explain = false)
30
30
  cmd_lambda = lang_config[:command]
31
31
  temp_file_suffix = lang_config[:temp_file_suffix]
32
32
 
33
33
  if temp_file_suffix
34
- execute_with_temp_file(code_content, cmd_lambda, temp_file_suffix, temp_dir, lang_key, input_file_path)
34
+ execute_with_temp_file(code_content, cmd_lambda, temp_file_suffix, temp_dir, lang_key, input_file_path, explain)
35
35
  else
36
- execute_direct_command(code_content, cmd_lambda)
36
+ execute_direct_command(code_content, cmd_lambda, explain)
37
37
  end
38
38
  end
39
39
 
40
- def execute_with_temp_file(code_content, cmd_lambda, temp_file_suffix, temp_dir, lang_key, input_file_path = nil)
40
+ def execute_with_temp_file(code_content, cmd_lambda, temp_file_suffix, temp_dir, lang_key, input_file_path = nil, explain = false)
41
41
  result = nil
42
42
  Tempfile.create([lang_key, temp_file_suffix], temp_dir) do |temp_file|
43
43
  temp_file.write(code_content)
44
44
  temp_file.close
45
- command_to_run, exec_options = cmd_lambda.call(code_content, temp_file.path, input_file_path)
45
+ command_to_run, exec_options = cmd_lambda.call(code_content, temp_file.path, input_file_path, explain)
46
46
 
47
47
  # Extract output_path if present (for mermaid)
48
48
  output_path = exec_options.delete(:output_path) if exec_options.is_a?(Hash)
@@ -58,19 +58,21 @@ class CodeExecutor
58
58
  result
59
59
  end
60
60
 
61
- def execute_direct_command(code_content, cmd_lambda)
62
- command_to_run, exec_options = cmd_lambda.call(code_content, nil)
61
+ def execute_direct_command(code_content, cmd_lambda, explain = false)
62
+ command_to_run, exec_options = cmd_lambda.call(code_content, nil, nil, explain)
63
63
  captured_stdout, captured_stderr, captured_status_obj = Open3.capture3(command_to_run, **exec_options)
64
64
  { stdout: captured_stdout, stderr: captured_stderr, status: captured_status_obj }
65
65
  end
66
66
 
67
- def process_execution_result(result, lang_config, lang_key)
67
+ def process_execution_result(result, lang_config, lang_key, explain = false)
68
68
  exit_status, result_output, stderr_output = format_captured_output(result, lang_config)
69
69
 
70
70
  if exit_status != 0
71
71
  result_output = add_error_to_output(exit_status, lang_config, lang_key, result_output, stderr_output)
72
72
  elsif lang_config && lang_config[:result_handling] == :mermaid_svg
73
73
  result_output = handle_mermaid_svg_result(result, lang_key)
74
+ elsif explain && lang_key == "psql"
75
+ result_output = handle_psql_explain_result(result_output)
74
76
  end
75
77
 
76
78
  result_output
@@ -118,7 +120,7 @@ class CodeExecutor
118
120
  # If the SVG is in a subdirectory, include the directory in the path
119
121
  output_dir = File.dirname(output_path)
120
122
  svg_filename = File.basename(output_path)
121
-
123
+
122
124
  # Check if SVG is in a subdirectory (new behavior) or same directory (fallback)
123
125
  parent_dir = File.dirname(output_dir)
124
126
  if File.basename(output_dir) != File.basename(parent_dir)
@@ -128,10 +130,83 @@ class CodeExecutor
128
130
  # SVG is in same directory (fallback behavior)
129
131
  relative_path = svg_filename
130
132
  end
131
-
133
+
132
134
  warn "Generated Mermaid SVG: #{relative_path}"
133
135
 
134
136
  # Return markdown image tag instead of typical result content
135
137
  "![Mermaid Diagram](#{relative_path})"
136
138
  end
139
+
140
+ def handle_psql_explain_result(result_output)
141
+ require 'json'
142
+ require 'net/http'
143
+ require 'uri'
144
+
145
+ # Try to parse the result as JSON (EXPLAIN output)
146
+ begin
147
+ # Clean up the result output and try to parse as JSON
148
+ json_data = JSON.parse(result_output.strip)
149
+
150
+ # Submit plan to Dalibo via POST request
151
+ dalibo_url = submit_plan_to_dalibo(JSON.generate(json_data))
152
+
153
+ if dalibo_url
154
+ # Return a special format that the markdown processor can parse
155
+ "DALIBO_LINK:#{dalibo_url}\n#{result_output.strip}"
156
+ else
157
+ # If submission failed, just return the original output
158
+ result_output
159
+ end
160
+ rescue JSON::ParserError
161
+ # If it's not valid JSON, just return the original output
162
+ result_output
163
+ end
164
+ end
165
+
166
+ private
167
+
168
+ def submit_plan_to_dalibo(plan_json)
169
+ begin
170
+ # Start with HTTPS directly to avoid the HTTP->HTTPS redirect
171
+ uri = URI('https://explain.dalibo.com/new')
172
+ http = Net::HTTP.new(uri.host, uri.port)
173
+ http.use_ssl = true
174
+ http.read_timeout = 10 # 10 seconds timeout
175
+
176
+ # Prepare the JSON payload
177
+ payload = {
178
+ 'plan' => plan_json,
179
+ 'title' => "Query Plan - #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}",
180
+ 'query' => ''
181
+ }
182
+
183
+ # Create the POST request
184
+ request = Net::HTTP::Post.new(uri)
185
+ request['Content-Type'] = 'application/json'
186
+ request.body = JSON.generate(payload)
187
+
188
+ # Send the request and follow redirects to get the final URL
189
+ response = http.request(request)
190
+
191
+ # Dalibo returns a redirect to the plan URL
192
+ if response.is_a?(Net::HTTPRedirection)
193
+ location = response['location']
194
+ # Make sure it's a full URL
195
+ if location
196
+ if location.start_with?('/')
197
+ location = "https://explain.dalibo.com#{location}"
198
+ end
199
+ location
200
+ else
201
+ nil
202
+ end
203
+ else
204
+ warn "Failed to submit plan to Dalibo: #{response.code} #{response.message}"
205
+ nil
206
+ end
207
+ rescue => e
208
+ warn "Error submitting plan to Dalibo: #{e.message}"
209
+ nil
210
+ end
211
+ end
137
212
  end
@@ -3,10 +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)
6
+ def initialize(current_block_run, current_block_rerun, current_block_lang, current_block_explain = 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
+ @current_block_explain = current_block_explain
11
+ @current_block_result = current_block_result
10
12
  end
11
13
 
12
14
  def decide(file_enum, result_block_regex_method)
@@ -19,6 +21,8 @@ class ExecutionDecider
19
21
  handle_immediate_result_block(file_enum)
20
22
  elsif is_blank_line?(peek1)
21
23
  handle_blank_line_scenario(file_enum, expected_header_regex)
24
+ elsif @current_block_explain && is_dalibo_link?(peek1)
25
+ handle_immediate_dalibo_link(file_enum)
22
26
  else
23
27
  execute_without_existing_result
24
28
  end
@@ -44,12 +48,23 @@ class ExecutionDecider
44
48
 
45
49
  def handle_blank_line_scenario(file_enum, expected_header_regex)
46
50
  consumed_blank_line = file_enum.next
51
+
52
+ # Look ahead past multiple blank lines to find actual content
47
53
  peek2 = peek_next_line(file_enum)
54
+ additional_blanks = []
55
+
56
+ # Consume consecutive blank lines
57
+ while is_blank_line?(peek2)
58
+ additional_blanks << file_enum.next
59
+ peek2 = peek_next_line(file_enum)
60
+ end
48
61
 
49
62
  if line_matches_pattern?(peek2, expected_header_regex)
50
- handle_result_after_blank_line(file_enum, consumed_blank_line)
63
+ handle_result_after_blank_lines(file_enum, consumed_blank_line, additional_blanks)
64
+ elsif @current_block_explain && is_dalibo_link?(peek2)
65
+ handle_dalibo_after_blank_lines(file_enum, consumed_blank_line, additional_blanks)
51
66
  else
52
- execute_with_blank_line(consumed_blank_line)
67
+ execute_with_blank_lines(consumed_blank_line, additional_blanks)
53
68
  end
54
69
  end
55
70
 
@@ -61,6 +76,24 @@ class ExecutionDecider
61
76
  end
62
77
  end
63
78
 
79
+ def handle_result_after_blank_lines(file_enum, consumed_blank_line, additional_blanks)
80
+ if @current_block_rerun
81
+ execute_with_consumed_result_and_blanks(file_enum, consumed_blank_line, additional_blanks)
82
+ else
83
+ skip_with_blanks_and_result(file_enum, consumed_blank_line, additional_blanks)
84
+ end
85
+ end
86
+
87
+ def handle_dalibo_after_blank_lines(file_enum, consumed_blank_line, additional_blanks)
88
+ # For explain result=false, always replace existing Dalibo links
89
+ # For explain result=true, follow normal rerun logic
90
+ if should_auto_replace_dalibo_link? || @current_block_rerun
91
+ execute_with_consumed_dalibo_and_blanks(file_enum, consumed_blank_line, additional_blanks)
92
+ else
93
+ skip_with_blanks_and_dalibo(file_enum, consumed_blank_line, additional_blanks)
94
+ end
95
+ end
96
+
64
97
  def execute_with_consumed_result(file_enum)
65
98
  consumed_lines = [file_enum.next]
66
99
  { execute: true, consumed_lines: consumed_lines, consume_existing: true }
@@ -83,10 +116,98 @@ class ExecutionDecider
83
116
  { execute: true, blank_line: consumed_blank_line }
84
117
  end
85
118
 
119
+ def execute_with_blank_lines(consumed_blank_line, additional_blanks)
120
+ { execute: true, blank_line: consumed_blank_line, additional_blanks: additional_blanks }
121
+ end
122
+
123
+ def execute_with_consumed_result_and_blanks(file_enum, consumed_blank_line, additional_blanks)
124
+ consumed_lines = [consumed_blank_line] + additional_blanks + [file_enum.next]
125
+ { execute: true, consumed_lines: consumed_lines, blank_line: consumed_blank_line, consume_existing: true }
126
+ end
127
+
128
+ def skip_with_blanks_and_result(file_enum, consumed_blank_line, additional_blanks)
129
+ lines_to_pass = [consumed_blank_line] + additional_blanks + [file_enum.next]
130
+ { execute: false, lines_to_pass_through: lines_to_pass }
131
+ end
132
+
133
+ def execute_with_consumed_dalibo_and_blanks(file_enum, consumed_blank_line, additional_blanks)
134
+ consumed_lines = [consumed_blank_line] + additional_blanks
135
+ consume_dalibo_links(file_enum, consumed_lines)
136
+ { execute: true, consumed_lines: consumed_lines, blank_line: consumed_blank_line, consume_existing_dalibo: true }
137
+ end
138
+
139
+ def skip_with_blanks_and_dalibo(file_enum, consumed_blank_line, additional_blanks)
140
+ consumed_lines = [consumed_blank_line] + additional_blanks
141
+ consume_dalibo_links(file_enum, consumed_lines)
142
+ { execute: false, lines_to_pass_through: consumed_lines, dalibo_content: true }
143
+ end
144
+
86
145
  def execute_without_existing_result
87
146
  { execute: true }
88
147
  end
89
148
 
149
+ def handle_immediate_dalibo_link(file_enum)
150
+ # For explain result=false, always replace existing Dalibo links
151
+ # For explain result=true, follow normal rerun logic
152
+ if should_auto_replace_dalibo_link? || @current_block_rerun
153
+ execute_with_consumed_dalibo(file_enum)
154
+ else
155
+ skip_with_existing_dalibo(file_enum)
156
+ end
157
+ end
158
+
159
+ def handle_dalibo_after_blank_line(file_enum, consumed_blank_line)
160
+ # For explain result=false, always replace existing Dalibo links
161
+ # For explain result=true, follow normal rerun logic
162
+ if should_auto_replace_dalibo_link? || @current_block_rerun
163
+ execute_with_consumed_dalibo_and_blank(file_enum, consumed_blank_line)
164
+ else
165
+ skip_with_blank_and_dalibo(file_enum, consumed_blank_line)
166
+ end
167
+ end
168
+
169
+ def execute_with_consumed_dalibo(file_enum)
170
+ consumed_lines = []
171
+ consume_dalibo_links(file_enum, consumed_lines)
172
+ { execute: true, consumed_lines: consumed_lines, consume_existing_dalibo: true }
173
+ end
174
+
175
+ def skip_with_existing_dalibo(file_enum)
176
+ consumed_lines = []
177
+ consume_dalibo_links(file_enum, consumed_lines)
178
+ { execute: false, lines_to_pass_through: consumed_lines, dalibo_content: true }
179
+ end
180
+
181
+ def execute_with_consumed_dalibo_and_blank(file_enum, consumed_blank_line)
182
+ consumed_lines = [consumed_blank_line]
183
+ consume_dalibo_links(file_enum, consumed_lines)
184
+ { execute: true, consumed_lines: consumed_lines, blank_line: consumed_blank_line, consume_existing_dalibo: true }
185
+ end
186
+
187
+ def skip_with_blank_and_dalibo(file_enum, consumed_blank_line)
188
+ consumed_lines = [consumed_blank_line]
189
+ consume_dalibo_links(file_enum, consumed_lines)
190
+ { execute: false, lines_to_pass_through: consumed_lines, dalibo_content: true }
191
+ end
192
+
193
+ def consume_dalibo_links(file_enum, consumed_lines)
194
+ # Consume all consecutive Dalibo links and blank lines
195
+ loop do
196
+ next_line = peek_next_line(file_enum)
197
+
198
+ if is_blank_line?(next_line) || is_dalibo_link?(next_line)
199
+ consumed_line = file_enum.next
200
+ consumed_lines << consumed_line
201
+ else
202
+ break
203
+ end
204
+ end
205
+ end
206
+
207
+ def is_dalibo_link?(line)
208
+ line&.start_with?("**Dalibo Visualization:**")
209
+ end
210
+
90
211
  def line_matches_pattern?(line, pattern)
91
212
  line && line.match?(pattern)
92
213
  end
@@ -94,4 +215,11 @@ class ExecutionDecider
94
215
  def is_blank_line?(line)
95
216
  line && line.strip == ""
96
217
  end
218
+
219
+ def should_auto_replace_dalibo_link?
220
+ # Auto-replace Dalibo links when using explain with result=false
221
+ # This makes sense because with result=false, there's only a Dalibo link,
222
+ # so it should be updated on each run
223
+ @current_block_explain && !@current_block_result
224
+ end
97
225
  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) {
4
+ command: ->(_code_content, temp_file_path, input_file_path = nil, explain = 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,22 +16,32 @@ JS_CONFIG = {
16
16
  }.freeze
17
17
 
18
18
  SQLITE_CONFIG = {
19
- command: ->(code_content, temp_file_path, input_file_path = nil) { [ "sqlite3 #{temp_file_path}", { stdin_data: code_content } ] },
19
+ command: ->(code_content, temp_file_path, input_file_path = nil, explain = 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) {
25
+ command: ->(code_content, _temp_file_path, input_file_path = nil, explain = 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
- [ "psql -A -t -X", { stdin_data: code_content } ]
30
+
31
+ # Modify the SQL query if explain option is enabled
32
+ if explain
33
+ # Wrap the query with EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
34
+ # Remove any trailing semicolons and whitespace, then add our EXPLAIN wrapper
35
+ clean_query = code_content.strip.gsub(/;\s*$/, '')
36
+ explained_query = "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) #{clean_query};"
37
+ [ "psql -A -t -X", { stdin_data: explained_query } ]
38
+ else
39
+ [ "psql -A -t -X", { stdin_data: code_content } ]
40
+ end
31
41
  }
32
42
  },
33
43
  "ruby" => {
34
- command: ->(_code_content, temp_file_path, input_file_path = nil) {
44
+ command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
35
45
  xmpfilter_exists = system("command -v xmpfilter > /dev/null 2>&1")
36
46
  unless xmpfilter_exists
37
47
  abort "Error: xmpfilter command not found. Please install xmpfilter or ensure it is in your PATH."
@@ -47,7 +57,7 @@ SUPPORTED_LANGUAGES = {
47
57
  "sqlite" => SQLITE_CONFIG,
48
58
  "sqlite3" => SQLITE_CONFIG, # Alias for sqlite
49
59
  "bash" => {
50
- command: ->(_code_content, temp_file_path, input_file_path = nil) {
60
+ command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
51
61
  bash_exists = system("command -v bash > /dev/null 2>&1")
52
62
  unless bash_exists
53
63
  abort "Error: bash command not found. Please ensure bash is in your PATH."
@@ -57,7 +67,7 @@ SUPPORTED_LANGUAGES = {
57
67
  temp_file_suffix: ".sh"
58
68
  },
59
69
  "zsh" => {
60
- command: ->(_code_content, temp_file_path, input_file_path = nil) {
70
+ command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
61
71
  zsh_exists = system("command -v zsh > /dev/null 2>&1")
62
72
  unless zsh_exists
63
73
  abort "Error: zsh command not found. Please ensure zsh is in your PATH."
@@ -67,7 +77,7 @@ SUPPORTED_LANGUAGES = {
67
77
  temp_file_suffix: ".zsh"
68
78
  },
69
79
  "sh" => {
70
- command: ->(_code_content, temp_file_path, input_file_path = nil) {
80
+ command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
71
81
  sh_exists = system("command -v sh > /dev/null 2>&1")
72
82
  unless sh_exists
73
83
  abort "Error: sh command not found. Please ensure sh is in your PATH."
@@ -77,7 +87,7 @@ SUPPORTED_LANGUAGES = {
77
87
  temp_file_suffix: ".sh"
78
88
  },
79
89
  "mermaid" => {
80
- command: ->(code_content, temp_file_path, input_file_path = nil) {
90
+ command: ->(code_content, temp_file_path, input_file_path = nil, explain = false) {
81
91
  mmdc_exists = system("command -v mmdc > /dev/null 2>&1")
82
92
  unless mmdc_exists
83
93
  abort "Error: mmdc command not found. Please install @mermaid-js/mermaid-cli: npm install -g @mermaid-js/mermaid-cli"
@@ -87,11 +97,11 @@ SUPPORTED_LANGUAGES = {
87
97
  if input_file_path
88
98
  # Extract markdown file basename without extension
89
99
  md_basename = File.basename(input_file_path, ".*")
90
-
100
+
91
101
  # Create directory named after the markdown file
92
102
  output_dir = File.join(File.dirname(input_file_path), md_basename)
93
103
  Dir.mkdir(output_dir) unless Dir.exist?(output_dir)
94
-
104
+
95
105
  # Generate unique filename with markdown basename prefix
96
106
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
97
107
  random_suffix = SecureRandom.hex(6)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Markdown
4
4
  module Run
5
- VERSION = "0.1.9"
5
+ VERSION = "0.1.10"
6
6
  end
7
7
  end
@@ -16,6 +16,8 @@ class MarkdownProcessor
16
16
  @current_code_content = ""
17
17
  @current_block_rerun = false
18
18
  @current_block_run = true
19
+ @current_block_explain = false
20
+ @current_block_result = true
19
21
  @frontmatter_parser = FrontmatterParser.new
20
22
  @code_block_parser = CodeBlockParser.new(@frontmatter_parser)
21
23
  end
@@ -103,6 +105,14 @@ class MarkdownProcessor
103
105
  @code_block_parser.parse_run_option(options_string)
104
106
  end
105
107
 
108
+ def parse_explain_option(options_string)
109
+ @code_block_parser.parse_explain_option(options_string)
110
+ end
111
+
112
+ def parse_result_option(options_string)
113
+ @code_block_parser.parse_result_option(options_string)
114
+ end
115
+
106
116
  def handle_line(current_line, file_enum)
107
117
  case @state
108
118
  when :outside_code_block
@@ -153,6 +163,8 @@ class MarkdownProcessor
153
163
  @current_block_lang = resolve_language(lang)
154
164
  @current_block_rerun = parse_rerun_option(options_string)
155
165
  @current_block_run = parse_run_option(options_string)
166
+ @current_block_explain = parse_explain_option(options_string)
167
+ @current_block_result = parse_result_option(options_string)
156
168
  @state = :inside_code_block
157
169
  @current_code_content = ""
158
170
  end
@@ -171,19 +183,22 @@ class MarkdownProcessor
171
183
  # If we consumed lines for rerun, don't add them to output (they'll be replaced)
172
184
  execute_and_add_result(decision[:blank_line])
173
185
  else
174
- skip_and_pass_through_result(decision[:lines_to_pass_through], file_enum)
186
+ skip_and_pass_through_result(decision[:lines_to_pass_through], file_enum, decision)
175
187
  end
176
188
 
177
189
  reset_code_block_state
178
190
  end
179
191
 
180
192
  def decide_execution(file_enum)
181
- decider = ExecutionDecider.new(@current_block_run, @current_block_rerun, @current_block_lang)
193
+ decider = ExecutionDecider.new(@current_block_run, @current_block_rerun, @current_block_lang, @current_block_explain, @current_block_result)
182
194
  decision = decider.decide(file_enum, method(:result_block_regex))
183
195
 
184
196
  # Handle the consume_existing flag for rerun scenarios
185
197
  if decision[:consume_existing]
186
198
  consume_existing_result_block(file_enum, decision[:consumed_lines])
199
+ elsif decision[:consume_existing_dalibo]
200
+ # Dalibo links are already consumed in the decision process
201
+ # Just acknowledge they were consumed
187
202
  end
188
203
 
189
204
  decision
@@ -193,20 +208,45 @@ class MarkdownProcessor
193
208
  @output_lines << blank_line_before_new_result if blank_line_before_new_result
194
209
 
195
210
  if has_content?(@current_code_content)
196
- result_output = CodeExecutor.execute(@current_code_content, @current_block_lang, @temp_dir, @input_file_path)
197
- add_result_block(result_output, blank_line_before_new_result)
211
+ result_output = CodeExecutor.execute(@current_code_content, @current_block_lang, @temp_dir, @input_file_path, @current_block_explain)
212
+
213
+ # Check if result contains a Dalibo link for psql explain queries
214
+ dalibo_link, clean_result = extract_dalibo_link(result_output)
215
+
216
+ # Add the result block only if result=true (default)
217
+ if @current_block_result
218
+ add_result_block(clean_result || result_output, blank_line_before_new_result)
219
+ end
220
+
221
+ # Always add Dalibo link if it exists, even when result=false
222
+ if dalibo_link
223
+ # Add appropriate spacing based on whether result block was shown
224
+ if @current_block_result
225
+ @output_lines << "#{dalibo_link}\n\n"
226
+ else
227
+ @output_lines << "\n#{dalibo_link}\n\n"
228
+ end
229
+ end
198
230
  else
199
231
  warn "Skipping empty code block for language '#{@current_block_lang}'."
200
232
  end
201
233
  end
202
234
 
203
- def skip_and_pass_through_result(lines_to_pass_through, file_enum)
235
+ def skip_and_pass_through_result(lines_to_pass_through, file_enum, decision = nil)
204
236
  # Handle run=false case where there are no lines to pass through
205
237
  if lines_to_pass_through.empty?
206
238
  warn "Skipping execution due to run=false option."
207
239
  return
208
240
  end
209
241
 
242
+ # Check if this is Dalibo content
243
+ if decision && decision[:dalibo_content]
244
+ warn "Found existing Dalibo link for current #{@current_block_lang} block, skipping execution."
245
+ @output_lines.concat(lines_to_pass_through)
246
+ # No additional consumption needed for Dalibo links
247
+ return
248
+ end
249
+
210
250
  if mermaid_style_result?(@current_block_lang)
211
251
  warn "Found existing mermaid SVG image for current #{@current_block_lang} block, skipping execution."
212
252
  @output_lines.concat(lines_to_pass_through)
@@ -235,6 +275,9 @@ class MarkdownProcessor
235
275
  consume_block_lines(file_enum) do |line|
236
276
  consumed_lines << line
237
277
  end
278
+
279
+ # After consuming the result block, check if there's a Dalibo link to consume as well
280
+ consume_dalibo_link_if_present(file_enum, consumed_lines)
238
281
  end
239
282
 
240
283
  def consume_block_lines(file_enum)
@@ -254,9 +297,46 @@ class MarkdownProcessor
254
297
  @current_code_content = ""
255
298
  @current_block_rerun = false
256
299
  @current_block_run = true
300
+ @current_block_explain = false
301
+ @current_block_result = true
257
302
  end
258
303
 
259
304
  def stderr_has_content?(stderr_output)
260
305
  stderr_output && !stderr_output.strip.empty?
261
306
  end
307
+
308
+ def extract_dalibo_link(result_output)
309
+ # Check if the result contains a Dalibo link marker
310
+ if result_output.start_with?("DALIBO_LINK:")
311
+ lines = result_output.split("\n", 2)
312
+ dalibo_url = lines[0].sub("DALIBO_LINK:", "")
313
+ clean_result = lines[1] || ""
314
+ dalibo_link = "**Dalibo Visualization:** [View Query Plan](#{dalibo_url})"
315
+ [dalibo_link, clean_result]
316
+ else
317
+ [nil, result_output]
318
+ end
319
+ end
320
+
321
+ def consume_dalibo_link_if_present(file_enum, consumed_lines)
322
+ # Look ahead to see if there are Dalibo links after the result block
323
+ begin
324
+ # Keep consuming blank lines and Dalibo links until we hit something else
325
+ loop do
326
+ next_line = peek_next_line(file_enum)
327
+
328
+ if is_blank_line?(next_line)
329
+ consumed_lines << file_enum.next
330
+ elsif next_line&.start_with?("**Dalibo Visualization:**")
331
+ consumed_lines << file_enum.next
332
+ else
333
+ # Hit something that's not a blank line or Dalibo link, stop consuming
334
+ break
335
+ end
336
+ end
337
+ rescue StopIteration
338
+ # End of file reached, nothing more to consume
339
+ end
340
+ end
341
+
262
342
  end
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.9
4
+ version: 0.1.10
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-02 00:00:00.000000000 Z
11
+ date: 2025-06-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rcodetools