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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +32 -2
- data/lib/code_block_parser.rb +21 -4
- data/lib/code_executor.rb +90 -15
- data/lib/execution_decider.rb +131 -3
- data/lib/language_configs.rb +21 -11
- data/lib/markdown/run/version.rb +1 -1
- data/lib/markdown_processor.rb +85 -5
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1139e0ccb6ed1b5b4ee2cef44cf067c83fc8a92ad750eae2e256725282534b68
|
4
|
+
data.tar.gz: 8887f778eaec56e6bdbb84d0a48f8db82fbd52cabbcdea01c48f4cffb5ff410b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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:
|
data/lib/code_block_parser.rb
CHANGED
@@ -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
|
-
#
|
55
|
-
|
56
|
-
|
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
|
-
|
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
|
""
|
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
|
data/lib/execution_decider.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
data/lib/language_configs.rb
CHANGED
@@ -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
|
-
|
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)
|
data/lib/markdown/run/version.rb
CHANGED
data/lib/markdown_processor.rb
CHANGED
@@ -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
|
-
|
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.
|
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-
|
11
|
+
date: 2025-06-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rcodetools
|