markdown-run 0.1.8 → 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/.tool-versions +1 -0
- data/CHANGELOG.md +11 -0
- data/README.md +51 -2
- data/Rakefile +22 -0
- data/exe/markdown-run +6 -433
- data/lib/code_block_parser.rb +77 -0
- data/lib/code_executor.rb +212 -0
- data/lib/enum_helper.rb +17 -0
- data/lib/execution_decider.rb +225 -0
- data/lib/frontmatter_parser.rb +72 -0
- data/lib/language_configs.rb +53 -8
- data/lib/markdown/run/version.rb +1 -1
- data/lib/markdown_file_writer.rb +25 -0
- data/lib/markdown_processor.rb +342 -0
- data/lib/markdown_run.rb +20 -0
- metadata +49 -11
- data/test_markdown_exec.rb +0 -297
@@ -0,0 +1,342 @@
|
|
1
|
+
require_relative "language_configs"
|
2
|
+
require_relative "frontmatter_parser"
|
3
|
+
require_relative "code_block_parser"
|
4
|
+
require_relative "code_executor"
|
5
|
+
require_relative "execution_decider"
|
6
|
+
require_relative "enum_helper"
|
7
|
+
|
8
|
+
class MarkdownProcessor
|
9
|
+
include EnumHelper
|
10
|
+
def initialize(temp_dir, input_file_path = nil)
|
11
|
+
@temp_dir = temp_dir
|
12
|
+
@input_file_path = input_file_path
|
13
|
+
@output_lines = []
|
14
|
+
@state = :outside_code_block
|
15
|
+
@current_block_lang = ""
|
16
|
+
@current_code_content = ""
|
17
|
+
@current_block_rerun = false
|
18
|
+
@current_block_run = true
|
19
|
+
@current_block_explain = false
|
20
|
+
@current_block_result = true
|
21
|
+
@frontmatter_parser = FrontmatterParser.new
|
22
|
+
@code_block_parser = CodeBlockParser.new(@frontmatter_parser)
|
23
|
+
end
|
24
|
+
|
25
|
+
def process_file(file_enum)
|
26
|
+
@frontmatter_parser.parse_frontmatter(file_enum, @output_lines)
|
27
|
+
|
28
|
+
loop do
|
29
|
+
current_line = get_next_line(file_enum)
|
30
|
+
break unless current_line
|
31
|
+
|
32
|
+
handle_line(current_line, file_enum)
|
33
|
+
end
|
34
|
+
@output_lines
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def resolve_language(lang)
|
40
|
+
@frontmatter_parser.resolve_language(lang)
|
41
|
+
end
|
42
|
+
|
43
|
+
def ruby_style_result?(lang)
|
44
|
+
lang_config = SUPPORTED_LANGUAGES[lang]
|
45
|
+
lang_config && lang_config[:result_block_type] == "ruby"
|
46
|
+
end
|
47
|
+
|
48
|
+
def mermaid_style_result?(lang)
|
49
|
+
lang_config = SUPPORTED_LANGUAGES[lang]
|
50
|
+
lang_config && lang_config[:result_handling] == :mermaid_svg
|
51
|
+
end
|
52
|
+
|
53
|
+
def result_block_header(lang)
|
54
|
+
ruby_style_result?(lang) ? "```ruby RESULT\n" : "```RESULT\n"
|
55
|
+
end
|
56
|
+
|
57
|
+
def result_block_regex(lang)
|
58
|
+
if mermaid_style_result?(lang)
|
59
|
+
# For mermaid, look for existing image tags with .svg extension
|
60
|
+
/^!\[.*\]\(.*\.svg\)$/i
|
61
|
+
elsif ruby_style_result?(lang)
|
62
|
+
/^```ruby\s+RESULT$/i
|
63
|
+
else
|
64
|
+
/^```RESULT$/i
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def is_block_end?(line)
|
69
|
+
@code_block_parser.is_block_end?(line)
|
70
|
+
end
|
71
|
+
|
72
|
+
def has_content?(content)
|
73
|
+
!content.strip.empty?
|
74
|
+
end
|
75
|
+
|
76
|
+
def add_result_block(result_output, blank_line_before_new_result)
|
77
|
+
if mermaid_style_result?(@current_block_lang)
|
78
|
+
# For mermaid, add the image tag directly without a result block
|
79
|
+
@output_lines << "\n" if blank_line_before_new_result.nil?
|
80
|
+
@output_lines << result_output
|
81
|
+
@output_lines << "\n" unless result_output.empty? || result_output.end_with?("\n")
|
82
|
+
@output_lines << "\n"
|
83
|
+
else
|
84
|
+
@output_lines << "\n" if blank_line_before_new_result.nil?
|
85
|
+
@output_lines << result_block_header(@current_block_lang)
|
86
|
+
@output_lines << result_output
|
87
|
+
@output_lines << "\n" unless result_output.empty? || result_output.end_with?("\n")
|
88
|
+
@output_lines << "```\n\n"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def line_matches_pattern?(line, pattern)
|
93
|
+
line && line.match?(pattern)
|
94
|
+
end
|
95
|
+
|
96
|
+
def is_blank_line?(line)
|
97
|
+
line && line.strip == ""
|
98
|
+
end
|
99
|
+
|
100
|
+
def parse_rerun_option(options_string)
|
101
|
+
@code_block_parser.parse_rerun_option(options_string)
|
102
|
+
end
|
103
|
+
|
104
|
+
def parse_run_option(options_string)
|
105
|
+
@code_block_parser.parse_run_option(options_string)
|
106
|
+
end
|
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
|
+
|
116
|
+
def handle_line(current_line, file_enum)
|
117
|
+
case @state
|
118
|
+
when :outside_code_block
|
119
|
+
handle_outside_code_block(current_line, file_enum)
|
120
|
+
when :inside_code_block
|
121
|
+
handle_inside_code_block(current_line, file_enum)
|
122
|
+
when :inside_result_block
|
123
|
+
handle_inside_result_block(current_line, file_enum)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def handle_outside_code_block(current_line, file_enum)
|
128
|
+
if @code_block_parser.is_ruby_result_block?(current_line)
|
129
|
+
handle_existing_ruby_result_block(current_line, file_enum)
|
130
|
+
else
|
131
|
+
parsed_header = @code_block_parser.parse_code_block_header(current_line)
|
132
|
+
if parsed_header && parsed_header[:is_supported]
|
133
|
+
start_code_block(current_line, parsed_header[:original_lang], parsed_header[:options_string])
|
134
|
+
else
|
135
|
+
@output_lines << current_line
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def handle_inside_code_block(current_line, file_enum)
|
141
|
+
if is_block_end?(current_line)
|
142
|
+
end_code_block(current_line, file_enum)
|
143
|
+
else
|
144
|
+
accumulate_code_content(current_line)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def handle_inside_result_block(current_line, file_enum)
|
149
|
+
@output_lines << current_line
|
150
|
+
if is_block_end?(current_line)
|
151
|
+
@state = :outside_code_block
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def handle_existing_ruby_result_block(current_line, file_enum)
|
156
|
+
warn "Found existing '```ruby RESULT' block, passing through."
|
157
|
+
@output_lines << current_line
|
158
|
+
@state = :inside_result_block
|
159
|
+
end
|
160
|
+
|
161
|
+
def start_code_block(current_line, lang, options_string = nil)
|
162
|
+
@output_lines << current_line
|
163
|
+
@current_block_lang = resolve_language(lang)
|
164
|
+
@current_block_rerun = parse_rerun_option(options_string)
|
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)
|
168
|
+
@state = :inside_code_block
|
169
|
+
@current_code_content = ""
|
170
|
+
end
|
171
|
+
|
172
|
+
def accumulate_code_content(current_line)
|
173
|
+
@current_code_content += current_line
|
174
|
+
@output_lines << current_line
|
175
|
+
end
|
176
|
+
|
177
|
+
def end_code_block(current_line, file_enum)
|
178
|
+
@output_lines << current_line
|
179
|
+
|
180
|
+
decision = decide_execution(file_enum)
|
181
|
+
|
182
|
+
if decision[:execute]
|
183
|
+
# If we consumed lines for rerun, don't add them to output (they'll be replaced)
|
184
|
+
execute_and_add_result(decision[:blank_line])
|
185
|
+
else
|
186
|
+
skip_and_pass_through_result(decision[:lines_to_pass_through], file_enum, decision)
|
187
|
+
end
|
188
|
+
|
189
|
+
reset_code_block_state
|
190
|
+
end
|
191
|
+
|
192
|
+
def decide_execution(file_enum)
|
193
|
+
decider = ExecutionDecider.new(@current_block_run, @current_block_rerun, @current_block_lang, @current_block_explain, @current_block_result)
|
194
|
+
decision = decider.decide(file_enum, method(:result_block_regex))
|
195
|
+
|
196
|
+
# Handle the consume_existing flag for rerun scenarios
|
197
|
+
if decision[:consume_existing]
|
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
|
202
|
+
end
|
203
|
+
|
204
|
+
decision
|
205
|
+
end
|
206
|
+
|
207
|
+
def execute_and_add_result(blank_line_before_new_result)
|
208
|
+
@output_lines << blank_line_before_new_result if blank_line_before_new_result
|
209
|
+
|
210
|
+
if has_content?(@current_code_content)
|
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
|
230
|
+
else
|
231
|
+
warn "Skipping empty code block for language '#{@current_block_lang}'."
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def skip_and_pass_through_result(lines_to_pass_through, file_enum, decision = nil)
|
236
|
+
# Handle run=false case where there are no lines to pass through
|
237
|
+
if lines_to_pass_through.empty?
|
238
|
+
warn "Skipping execution due to run=false option."
|
239
|
+
return
|
240
|
+
end
|
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
|
+
|
250
|
+
if mermaid_style_result?(@current_block_lang)
|
251
|
+
warn "Found existing mermaid SVG image for current #{@current_block_lang} block, skipping execution."
|
252
|
+
@output_lines.concat(lines_to_pass_through)
|
253
|
+
# For mermaid, no additional consumption needed since it's just an image line
|
254
|
+
else
|
255
|
+
lang_specific_result_type = ruby_style_result?(@current_block_lang) ? "```ruby RESULT" : "```RESULT"
|
256
|
+
warn "Found existing '#{lang_specific_result_type}' block for current #{@current_block_lang} block, skipping execution."
|
257
|
+
@output_lines.concat(lines_to_pass_through)
|
258
|
+
consume_result_block_content(file_enum)
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
def consume_result_block_content(file_enum)
|
263
|
+
consume_block_lines(file_enum) do |line|
|
264
|
+
@output_lines << line
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
def consume_existing_result_block(file_enum, consumed_lines)
|
269
|
+
if mermaid_style_result?(@current_block_lang)
|
270
|
+
# For mermaid, there's no result block to consume, just the image line
|
271
|
+
# The image line should already be in consumed_lines from ExecutionDecider
|
272
|
+
return
|
273
|
+
end
|
274
|
+
|
275
|
+
consume_block_lines(file_enum) do |line|
|
276
|
+
consumed_lines << line
|
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)
|
281
|
+
end
|
282
|
+
|
283
|
+
def consume_block_lines(file_enum)
|
284
|
+
begin
|
285
|
+
loop do
|
286
|
+
result_block_line = file_enum.next
|
287
|
+
yield result_block_line
|
288
|
+
break if is_block_end?(result_block_line)
|
289
|
+
end
|
290
|
+
rescue StopIteration
|
291
|
+
warn "Warning: End of file reached while consuming result block."
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
def reset_code_block_state
|
296
|
+
@state = :outside_code_block
|
297
|
+
@current_code_content = ""
|
298
|
+
@current_block_rerun = false
|
299
|
+
@current_block_run = true
|
300
|
+
@current_block_explain = false
|
301
|
+
@current_block_result = true
|
302
|
+
end
|
303
|
+
|
304
|
+
def stderr_has_content?(stderr_output)
|
305
|
+
stderr_output && !stderr_output.strip.empty?
|
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
|
+
|
342
|
+
end
|
data/lib/markdown_run.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
require_relative "language_configs"
|
2
|
+
require_relative "markdown_processor"
|
3
|
+
require_relative "markdown_file_writer"
|
4
|
+
|
5
|
+
module MarkdownRun
|
6
|
+
def self.run_code_blocks(input_file_path)
|
7
|
+
unless File.exist?(input_file_path) && File.readable?(input_file_path)
|
8
|
+
abort "Error: Input file '#{input_file_path}' not found or not readable."
|
9
|
+
end
|
10
|
+
|
11
|
+
temp_dir = File.dirname(File.expand_path(input_file_path))
|
12
|
+
file_enum = File.foreach(input_file_path, chomp: false).to_enum
|
13
|
+
|
14
|
+
processor = MarkdownProcessor.new(temp_dir, input_file_path)
|
15
|
+
output_lines = processor.process_file(file_enum)
|
16
|
+
|
17
|
+
# Write the modified content back to the input file
|
18
|
+
MarkdownFileWriter.write_output_to_file(output_lines, input_file_path)
|
19
|
+
end
|
20
|
+
end
|
metadata
CHANGED
@@ -1,29 +1,29 @@
|
|
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
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
|
-
- -
|
17
|
+
- - '='
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
19
|
+
version: 0.8.5
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
|
-
- -
|
24
|
+
- - '='
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version:
|
26
|
+
version: 0.8.5
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: minitest
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -31,15 +31,44 @@ dependencies:
|
|
31
31
|
- - '='
|
32
32
|
- !ruby/object:Gem::Version
|
33
33
|
version: 5.25.5
|
34
|
-
type: :
|
34
|
+
type: :development
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
38
|
- - '='
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: 5.25.5
|
41
|
-
|
42
|
-
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: flog
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
description: Run code blocks in Markdown files for Ruby, JavaScript, sqlite, psql,
|
70
|
+
bash, zsh, and mermaid. Insert execution results next to the original code blocks.
|
71
|
+
Generate SVG diagrams from mermaid blocks.
|
43
72
|
email:
|
44
73
|
- 32635+aurelienbottazini@users.noreply.github.com
|
45
74
|
executables:
|
@@ -48,15 +77,24 @@ extensions: []
|
|
48
77
|
extra_rdoc_files: []
|
49
78
|
files:
|
50
79
|
- ".rubocop.yml"
|
80
|
+
- ".tool-versions"
|
51
81
|
- CHANGELOG.md
|
52
82
|
- CODE_OF_CONDUCT.md
|
53
83
|
- LICENSE.txt
|
54
84
|
- README.md
|
85
|
+
- Rakefile
|
55
86
|
- exe/markdown-run
|
87
|
+
- lib/code_block_parser.rb
|
88
|
+
- lib/code_executor.rb
|
89
|
+
- lib/enum_helper.rb
|
90
|
+
- lib/execution_decider.rb
|
91
|
+
- lib/frontmatter_parser.rb
|
56
92
|
- lib/language_configs.rb
|
57
93
|
- lib/markdown/run/version.rb
|
94
|
+
- lib/markdown_file_writer.rb
|
95
|
+
- lib/markdown_processor.rb
|
96
|
+
- lib/markdown_run.rb
|
58
97
|
- markdown-run-sample.md
|
59
|
-
- test_markdown_exec.rb
|
60
98
|
homepage: https://github.com/aurelienbottazini/markdown-run
|
61
99
|
licenses:
|
62
100
|
- MIT
|
@@ -79,7 +117,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
79
117
|
- !ruby/object:Gem::Version
|
80
118
|
version: '0'
|
81
119
|
requirements: []
|
82
|
-
rubygems_version: 3.
|
120
|
+
rubygems_version: 3.5.16
|
83
121
|
signing_key:
|
84
122
|
specification_version: 4
|
85
123
|
summary: Run code blocks in Markdown files
|