markdown-run 0.1.5 → 0.1.7

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: 0b936d0e6cdca8201af8d50e9bf92919de41f768973712ef0c4d46839de3d00a
4
- data.tar.gz: 9b72d1b9628b665c0fd0efcf71d2a75c746226b1593d1b4b338e94da8c774020
3
+ metadata.gz: c11a9addb3e251e9830fe2126d2d752dc8c04c879b607d1ee0ad80998d51b294
4
+ data.tar.gz: '014954e8178d28729dfa86350a9e92aa91010892e6d177a5f7616dfd0e9ad72b'
5
5
  SHA512:
6
- metadata.gz: 105eb78e176854df961af1227c5eb9dc7c51c7647b46f14612560d53b2551d5292475450ee88065cfe85aef3962b17d0f464179a2f4f6fd9426435359344e50c
7
- data.tar.gz: 5f8f611391d234a6cf5fc19784c8bb14604fd8da27589c93de714226773633ba4ca1b7c36ce7259e6899cc90d0283fc0e20b50b2ef5a67b728b3703fba78e18a
6
+ metadata.gz: 5a26f680f607d52d7ceb0b0f31cca84af2a811ed2a70f6f73f9ab6ae3f9032f9135943c9e765df4d9714cbbd766179de14bab8006148e1d65d27a26388dd935c
7
+ data.tar.gz: 3ed109437211b62afd2dcdf1469e3103db9bd36a13be207f0ea636d67163c269e4a199e9a4b901ed4f7756319b5aa9c20f00e7613106be463c5139965f209fbf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.7] - 2025-06-01
4
+
5
+ - Added rerun functionality
6
+
7
+ ## [0.1.6] - 2025-06-01
8
+
9
+ - Refactor code to state pattern
10
+ - Add yaml frontmatter to support aliases for code blocks
11
+
3
12
  ## [0.1.5] - 2025-05-19
4
13
 
5
14
  - Remove gif files from release
data/README.md CHANGED
@@ -8,6 +8,15 @@ Do not rerun code blocks if result block is present.
8
8
 
9
9
  Meant to be used from the terminal or from an editor with a keybinding.
10
10
 
11
+ Supported languages:
12
+
13
+ - Javascript
14
+ - Ruby
15
+ - sqlite3
16
+ - postgresql
17
+ - bash
18
+ - zsh
19
+
11
20
  ## Installation
12
21
 
13
22
  `gem install markdown-run`
@@ -43,6 +52,29 @@ example vscode keybinding
43
52
  },
44
53
  ```
45
54
 
55
+ ### Code block options
56
+
57
+ - `rerun=true` or `rerun=false` for a code block to rerun or skip execution. `rerun=true` is the default if not specified
58
+
59
+ example:
60
+
61
+ ```js rerun=false
62
+ console.log("hello world");
63
+ ```
64
+
65
+ ## Frontmatter
66
+
67
+ You can add a yaml frontmatter to redefine code block behavior.
68
+
69
+ For example sql blocks run by default against sqlite
70
+ To have them run with postgres you can add at the top of your markdown file:
71
+
72
+ ```yaml
73
+ markdown-run:
74
+ alias:
75
+ - sql: psql
76
+ ```
77
+
46
78
  ## Demo
47
79
 
48
80
  ![VSCode Usage](docs/markdown-run-vscode.gif)
data/exe/markdown-run CHANGED
@@ -3,119 +3,61 @@
3
3
  require "tempfile"
4
4
  require "open3"
5
5
  require "fileutils"
6
-
7
- # --- Language Execution Configuration ---
8
- JS_CONFIG = {
9
- command: ->(_code_content, temp_file_path) {
10
- # Check if bun is available
11
- bun_exists = system("command -v bun > /dev/null 2>&1")
12
- if bun_exists
13
- [ "bun #{temp_file_path}", {} ]
14
- else
15
- # Fallback to node if bun is not available
16
- [ "node #{temp_file_path}", {} ]
17
- end
18
- },
19
- temp_file_suffix: ".js",
20
- error_handling: :js_specific # For specific stderr appending on error
21
- }.freeze
22
-
23
- SQLITE_CONFIG = {
24
- command: ->(code_content, temp_file_path) { [ "sqlite3 #{temp_file_path}", { stdin_data: code_content } ] },
25
- temp_file_suffix: ".db" # Temp file is the database
26
- }.freeze
27
-
28
- SUPPORTED_LANGUAGES = {
29
- "psql" => {
30
- command: ->(code_content, _temp_file_path) {
31
- psql_exists = system("command -v psql > /dev/null 2>&1")
32
- unless psql_exists
33
- abort "Error: psql command not found. Please install PostgreSQL or ensure psql is in your PATH."
34
- end
35
- [ "psql -A -t -X", { stdin_data: code_content } ]
36
- }
37
- },
38
- "ruby" => {
39
- command: ->(_code_content, temp_file_path) {
40
- xmpfilter_exists = system("command -v xmpfilter > /dev/null 2>&1")
41
- unless xmpfilter_exists
42
- abort "Error: xmpfilter command not found. Please install xmpfilter or ensure it is in your PATH."
43
- end
44
- [ "xmpfilter #{temp_file_path}", {} ]
45
- },
46
- temp_file_suffix: ".rb",
47
- result_block_type: "ruby" # For special '```ruby RESULT' blocks
48
- },
49
- "js" => JS_CONFIG,
50
- "javascript" => JS_CONFIG, # Alias for js
51
- "sqlite" => SQLITE_CONFIG,
52
- "sqlite3" => SQLITE_CONFIG, # Alias for sqlite
53
- "bash" => {
54
- command: ->(_code_content, temp_file_path) {
55
- bash_exists = system("command -v bash > /dev/null 2>&1")
56
- unless bash_exists
57
- abort "Error: bash command not found. Please ensure bash is in your PATH."
58
- end
59
- [ "bash #{temp_file_path}", {} ]
60
- },
61
- temp_file_suffix: ".sh"
62
- },
63
- "zsh" => {
64
- command: ->(_code_content, temp_file_path) {
65
- zsh_exists = system("command -v zsh > /dev/null 2>&1")
66
- unless zsh_exists
67
- abort "Error: zsh command not found. Please ensure zsh is in your PATH."
68
- end
69
- [ "zsh #{temp_file_path}", {} ]
70
- },
71
- temp_file_suffix: ".zsh"
72
- },
73
- "sh" => {
74
- command: ->(_code_content, temp_file_path) {
75
- sh_exists = system("command -v sh > /dev/null 2>&1")
76
- unless sh_exists
77
- abort "Error: sh command not found. Please ensure sh is in your PATH."
78
- end
79
- [ "sh #{temp_file_path}", {} ]
80
- },
81
- temp_file_suffix: ".sh"
82
- }
83
- }.freeze
84
-
85
- LANGUAGE_REGEX_PART = SUPPORTED_LANGUAGES.keys.map { |lang| Regexp.escape(lang) }.join("|").freeze
86
- CODE_BLOCK_START_REGEX = /^```(#{LANGUAGE_REGEX_PART})$/i
87
- # --- End Language Execution Configuration ---
6
+ require "yaml"
7
+ require_relative "../lib/language_configs"
88
8
 
89
9
  # Script to process markdown files, execute code blocks based on language,
90
10
  # and insert their results back into the markdown.
91
11
 
12
+ def stderr_has_content?(stderr_output)
13
+ stderr_output && !stderr_output.strip.empty?
14
+ end
15
+
16
+ def format_captured_output(captured_status_obj, captured_stderr, captured_stdout, lang_config)
17
+ result_output = captured_stdout
18
+ stderr_output = captured_stderr
19
+ exit_status = captured_status_obj.exitstatus
20
+
21
+ # JS-specific: Append stderr to result if execution failed and stderr has content
22
+ if lang_config && lang_config[:error_handling] == :js_specific && exit_status != 0 && stderr_has_content?(stderr_output)
23
+ result_output += "\nStderr:\n#{stderr_output.strip}"
24
+ end
25
+ return exit_status, result_output, stderr_output
26
+ end
27
+
28
+ def add_error_to_output(exit_status, lang_config, lang_key, result_output, stderr_output)
29
+ warn "Code execution failed for language '#{lang_key}' with status #{exit_status}."
30
+ warn "Stderr:\n#{stderr_output}" if stderr_has_content?(stderr_output)
31
+
32
+ is_js_error_already_formatted = lang_config && lang_config[:error_handling] == :js_specific && result_output.include?("Stderr:")
33
+ unless result_output.downcase.include?("error:") || is_js_error_already_formatted
34
+ error_prefix = "Execution failed (status: #{exit_status})."
35
+ error_prefix += " Stderr: #{stderr_output.strip}" if stderr_has_content?(stderr_output)
36
+ result_output = "#{error_prefix}\n#{result_output}"
37
+ end
38
+ result_output
39
+ end
40
+
92
41
  def execute_code_block(code_content, lang, temp_dir)
93
- result_output = ""
94
- stderr_output = ""
95
- exit_status = 0
96
- captured_stdout = nil
97
- captured_stderr = nil
98
42
  captured_status_obj = nil
99
43
 
100
44
  lang_key = lang.downcase # Normalize lang input for lookup
101
45
  lang_config = SUPPORTED_LANGUAGES[lang_key]
102
46
 
103
47
  if lang_config
48
+ exit_status = 0
104
49
  warn "Executing #{lang_key} code block..." # Generic description
105
50
  cmd_lambda = lang_config[:command]
106
51
  temp_file_suffix = lang_config[:temp_file_suffix]
107
52
 
108
- # Determine command and options using the lambda
109
- # The lambda receives code_content and a potential temp_file_path
110
- # It returns [command_string, options_hash_for_open3]
111
-
53
+ captured_stdout = nil
112
54
  if temp_file_suffix # Needs a temporary file. Use lang_key as prefix.
113
55
  Tempfile.create([ lang_key, temp_file_suffix ], temp_dir) do |temp_file|
114
56
  temp_file.write(code_content)
115
57
  temp_file.close
116
58
  # Pass temp_file.path. Lambda decides if it needs code_content directly.
117
59
  command_to_run, exec_options = cmd_lambda.call(code_content, temp_file.path)
118
- captured_stdout, captured_stderr, captured_status_obj = Open3.capture3(command_to_run, **exec_options)
60
+ captured_stdout, _, captured_status_obj = Open3.capture3(command_to_run, **exec_options)
119
61
  end
120
62
  else # Direct command execution (e.g., psql that takes stdin)
121
63
  # Pass nil for temp_file_path. Lambda decides if it needs code_content.
@@ -129,292 +71,349 @@ def execute_code_block(code_content, lang, temp_dir)
129
71
  # captured_status_obj remains nil, so common assignments below won't run
130
72
  end
131
73
 
132
- # Common assignment logic for cases that used Open3.capture3
133
74
  if captured_status_obj
134
- result_output = captured_stdout
135
- stderr_output = captured_stderr
136
- exit_status = captured_status_obj.exitstatus
137
-
138
- # JS-specific: Append stderr to result if execution failed and stderr has content
139
- if lang_config && lang_config[:error_handling] == :js_specific && exit_status != 0 && stderr_output && !stderr_output.strip.empty?
140
- result_output += "\nStderr:\n#{stderr_output.strip}" # Ensure stripping
141
- end
75
+ exit_status, result_output, stderr_output = format_captured_output(captured_status_obj, captured_stderr, captured_stdout, lang_config)
142
76
  end
143
77
 
144
- # Common error message enhancement
145
78
  if exit_status != 0
146
- warn "Code execution failed for language '#{lang_key}' with status #{exit_status}."
147
- warn "Stderr:\n#{stderr_output}" if stderr_output && !stderr_output.strip.empty?
148
-
149
- is_js_error_already_formatted = lang_config && lang_config[:error_handling] == :js_specific && result_output.include?("Stderr:")
150
- unless result_output.downcase.include?("error:") || is_js_error_already_formatted
151
- error_prefix = "Execution failed (status: #{exit_status})."
152
- error_prefix += " Stderr: #{stderr_output.strip}" if stderr_output && !stderr_output.strip.empty?
153
- result_output = "#{error_prefix}\n#{result_output}"
154
- end
79
+ result_output = add_error_to_output(exit_status, lang_config, lang_key, result_output, stderr_output)
155
80
  end
156
81
  result_output
157
82
  end
158
83
 
159
- def process_markdown_file_main(input_file_path)
160
- unless File.exist?(input_file_path) && File.readable?(input_file_path)
161
- warn "Error: Input file '#{input_file_path}' not found or not readable."
162
- return false # Indicate failure
84
+ class MarkdownProcessor
85
+ def initialize(temp_dir)
86
+ @temp_dir = temp_dir
87
+ @output_lines = []
88
+ @state = :outside_code_block
89
+ @current_block_lang = ""
90
+ @current_code_content = ""
91
+ @current_block_rerun = false
92
+ @aliases = {}
163
93
  end
164
94
 
165
- temp_dir = File.dirname(File.expand_path(input_file_path))
166
- output_lines = []
167
- file_enum = File.foreach(input_file_path, chomp: false).to_enum
95
+ def process_file(file_enum)
96
+ parse_frontmatter(file_enum)
168
97
 
169
- in_code_block = false
170
- current_block_lang = ""
171
- current_code_content = ""
98
+ loop do
99
+ current_line = get_next_line(file_enum)
100
+ break unless current_line
172
101
 
173
- loop do
174
- current_line = nil
175
- begin
176
- current_line = file_enum.next
177
- rescue StopIteration
178
- break # End of file
102
+ handle_line(current_line, file_enum)
179
103
  end
104
+ @output_lines
105
+ end
180
106
 
181
- if !in_code_block
182
- # Case 1: A ```ruby RESULT block encountered directly
183
- if current_line.match?(/^```ruby\s+RESULT$/i)
184
- warn "Found existing '```ruby RESULT' block, passing through."
185
- output_lines << current_line # The ```ruby RESULT line
186
- begin
187
- loop do
188
- block_line = file_enum.next
189
- output_lines << block_line
190
- break if block_line.strip == "```"
191
- end
192
- rescue StopIteration
193
- warn "Warning: End of file reached inside a '```ruby RESULT' block."
194
- break # Exit main loop, as the file is malformed or ended abruptly
195
- end
196
- next # Continue to the next line from the file
197
- # Case 2: Start of a new executable code block ```lang
198
- elsif (match_data = current_line.match(CODE_BLOCK_START_REGEX))
199
- output_lines << current_line # The opening ```lang line
200
- current_block_lang = match_data[1].downcase
201
- in_code_block = true
202
- current_code_content = "" # Reset for the new block
203
- next # Continue to the next line from the file
204
- # Case 3: Any other line (normal text, non-executable ```foo, or generic ```RESULT not after an exec block)
205
- else
206
- output_lines << current_line
207
- next # Continue to the next line from the file
107
+ private
108
+
109
+ def parse_frontmatter(file_enum)
110
+ first_line = peek_next_line(file_enum)
111
+ return unless first_line&.strip == "---"
112
+
113
+ # Consume the opening ---
114
+ @output_lines << file_enum.next
115
+ frontmatter_lines = []
116
+
117
+ loop do
118
+ line = get_next_line(file_enum)
119
+ break unless line
120
+
121
+ if line.strip == "---"
122
+ @output_lines << line
123
+ break
208
124
  end
209
- elsif current_line.strip == "```" # We are in_code_block (current_block_lang is set, current_code_content is accumulating)
210
- # Case 4: End of the current code block (```)
211
- output_lines << current_line # The closing ``` of the code block
212
125
 
213
- execute_this_block = true # Assume execution by default
214
- lines_to_pass_through_if_skipped = [] # For blank line + RESULT header if skipping
215
- blank_line_before_new_result = nil # If a blank line is consumed but block is still executed
126
+ frontmatter_lines << line
127
+ @output_lines << line
128
+ end
216
129
 
217
- # Peek ahead logic
218
- peek1 = nil
219
- begin; peek1 = file_enum.peek; rescue StopIteration; end # If EOF, execute_this_block remains true
130
+ return if frontmatter_lines.empty?
220
131
 
221
- current_lang_config = SUPPORTED_LANGUAGES[current_block_lang] # Get config for the current block
222
- is_ruby_style_result = current_lang_config && current_lang_config[:result_block_type] == "ruby"
132
+ begin
133
+ frontmatter = YAML.safe_load(frontmatter_lines.join)
134
+ extract_aliases(frontmatter) if frontmatter.is_a?(Hash)
135
+ rescue YAML::SyntaxError => e
136
+ warn "Warning: Invalid YAML frontmatter: #{e.message}"
137
+ end
138
+ end
223
139
 
224
- expected_header_regex = is_ruby_style_result ? /^```ruby\s+RESULT$/i : /^```RESULT$/i
140
+ def extract_aliases(frontmatter)
141
+ markdown_run_config = frontmatter["markdown-run"]
142
+ return unless markdown_run_config.is_a?(Hash)
225
143
 
226
- if peek1 && peek1.match?(expected_header_regex)
227
- execute_this_block = false
228
- lines_to_pass_through_if_skipped << file_enum.next # Consume RESULT header
229
- elsif peek1 && peek1.strip == "" # Blank line detected
230
- consumed_blank_line = file_enum.next # Consume the blank line from enum
144
+ aliases = markdown_run_config["alias"]
145
+ return unless aliases.is_a?(Array)
231
146
 
232
- peek2 = nil
233
- begin; peek2 = file_enum.peek; rescue StopIteration; end # EOF after blank line
147
+ aliases.each do |alias_config|
148
+ next unless alias_config.is_a?(Hash)
234
149
 
235
- if peek2 && peek2.match?(expected_header_regex)
236
- execute_this_block = false
237
- lines_to_pass_through_if_skipped << consumed_blank_line # Add consumed blank line
238
- lines_to_pass_through_if_skipped << file_enum.next # Consume RESULT header
239
- else
240
- # Blank line, but not followed by a RESULT header (or EOF after blank).
241
- # We will execute the block. The consumed_blank_line should be outputted.
242
- blank_line_before_new_result = consumed_blank_line
243
- # execute_this_block remains true
244
- end
245
- end
246
- # If peek1 was nil (EOF) or something else not matching, execute_this_block remains true.
247
-
248
- if execute_this_block
249
- output_lines << blank_line_before_new_result if blank_line_before_new_result
250
- if !current_code_content.strip.empty?
251
- warn "Executing #{current_block_lang} code block..."
252
- result_output = execute_code_block(current_code_content, current_block_lang, temp_dir)
253
-
254
- output_lines << "\n" if blank_line_before_new_result.nil?
255
- output_lines << (is_ruby_style_result ? "```ruby RESULT\n" : "```RESULT\n")
256
- output_lines << result_output
257
- output_lines << "\n" unless result_output.empty? || result_output.end_with?("\n")
258
- output_lines << "```\n\n" # Ensures a blank line after the RESULT block
259
- else
260
- warn "Skipping empty code block for language '#{current_block_lang}'."
261
- end
262
- else # Do not execute; a RESULT block follows and was identified
263
- lang_specific_result_type = is_ruby_style_result ? "```ruby RESULT" : "```RESULT"
264
- warn "Found existing '#{lang_specific_result_type}' block for current #{current_block_lang} block, skipping execution."
265
-
266
- output_lines.concat(lines_to_pass_through_if_skipped) # Add blank line (if any) and RESULT header
267
-
268
- # Consume the rest of the RESULT block content until its closing ```
269
- begin
270
- loop do
271
- result_block_line = file_enum.next
272
- output_lines << result_block_line
273
- break if result_block_line.strip == "```"
274
- end
275
- rescue StopIteration
276
- warn "Warning: End of file reached inside a skipped 'RESULT' block."
277
- break # Exit main loop
278
- end
150
+ alias_config.each do |alias_name, target_lang|
151
+ @aliases[alias_name.to_s] = target_lang.to_s
279
152
  end
153
+ end
154
+ end
280
155
 
281
- in_code_block = false
282
- current_code_content = "" # Reset for next block
283
- next
284
- # Case 5: Line inside an active code block
285
- else
286
- current_code_content += current_line
287
- output_lines << current_line # This line is part of the original code block being recorded
288
- next # Continue to the next line from the file
156
+ def resolve_language(lang)
157
+ @aliases[lang] || lang
158
+ end
159
+
160
+ def ruby_style_result?(lang)
161
+ lang_config = SUPPORTED_LANGUAGES[lang]
162
+ lang_config && lang_config[:result_block_type] == "ruby"
163
+ end
164
+
165
+ def result_block_header(lang)
166
+ ruby_style_result?(lang) ? "```ruby RESULT\n" : "```RESULT\n"
167
+ end
168
+
169
+ def result_block_regex(lang)
170
+ ruby_style_result?(lang) ? /^```ruby\s+RESULT$/i : /^```RESULT$/i
171
+ end
172
+
173
+ def is_block_end?(line)
174
+ line.strip == "```"
175
+ end
176
+
177
+ def has_content?(content)
178
+ !content.strip.empty?
179
+ end
180
+
181
+ def add_result_block(result_output, blank_line_before_new_result)
182
+ @output_lines << "\n" if blank_line_before_new_result.nil?
183
+ @output_lines << result_block_header(@current_block_lang)
184
+ @output_lines << result_output
185
+ @output_lines << "\n" unless result_output.empty? || result_output.end_with?("\n")
186
+ @output_lines << "```\n\n"
187
+ end
188
+
189
+ def line_matches_pattern?(line, pattern)
190
+ line && line.match?(pattern)
191
+ end
192
+
193
+ def is_blank_line?(line)
194
+ line && line.strip == ""
195
+ end
196
+
197
+ def parse_rerun_option(options_string)
198
+ return false unless options_string
199
+
200
+ # Match rerun=true or rerun=false
201
+ match = options_string.match(/rerun\s*=\s*(true|false)/i)
202
+ return false unless match
203
+
204
+ match[1].downcase == "true"
205
+ end
206
+
207
+ def safe_enum_operation(file_enum, operation)
208
+ file_enum.send(operation)
209
+ rescue StopIteration
210
+ nil
211
+ end
212
+
213
+ def get_next_line(file_enum)
214
+ safe_enum_operation(file_enum, :next)
215
+ end
216
+
217
+ def peek_next_line(file_enum)
218
+ safe_enum_operation(file_enum, :peek)
219
+ end
220
+
221
+ def handle_line(current_line, file_enum)
222
+ case @state
223
+ when :outside_code_block
224
+ handle_outside_code_block(current_line, file_enum)
225
+ when :inside_code_block
226
+ handle_inside_code_block(current_line, file_enum)
227
+ when :inside_result_block
228
+ handle_inside_result_block(current_line, file_enum)
289
229
  end
290
- end # loop
230
+ end
291
231
 
292
- # Write the modified content back to the input file
293
- Tempfile.create([ "md_exec_out_", File.extname(input_file_path) ], temp_dir) do |temp_output_file|
294
- temp_output_file.write(output_lines.join(""))
295
- temp_output_file.close
296
- begin
297
- FileUtils.mv(temp_output_file.path, input_file_path)
298
- rescue Errno::EACCES, Errno::EXDEV
299
- warn "Atomic move failed. Falling back to copy and delete."
300
- FileUtils.cp(temp_output_file.path, input_file_path)
301
- FileUtils.rm_f(temp_output_file.path)
232
+ def handle_outside_code_block(current_line, file_enum)
233
+ if current_line.match?(/^```ruby\s+RESULT$/i)
234
+ handle_existing_ruby_result_block(current_line, file_enum)
235
+ elsif (match_data = current_line.match(/^```(\w+)(?:\s+(.*))?$/i))
236
+ lang = match_data[1].downcase
237
+ options_string = match_data[2]
238
+ resolved_lang = resolve_language(lang)
239
+ if SUPPORTED_LANGUAGES.key?(resolved_lang)
240
+ start_code_block(current_line, lang, options_string)
241
+ else
242
+ @output_lines << current_line
243
+ end
244
+ else
245
+ @output_lines << current_line
302
246
  end
303
247
  end
304
- warn "Markdown processing complete. Output written to #{input_file_path}"
305
- true # Indicate success
306
- end
307
248
 
308
- if ARGV.empty?
249
+ def handle_inside_code_block(current_line, file_enum)
250
+ if is_block_end?(current_line)
251
+ end_code_block(current_line, file_enum)
252
+ else
253
+ accumulate_code_content(current_line)
254
+ end
255
+ end
309
256
 
310
- require "minitest/spec"
311
- require "bundler/inline"
312
- gemfile(true) do
313
- source "https://rubygems.org"
314
- gem "minitest", "5.25.5" # Specify the required version
315
- gem "rcodetools"
257
+ def handle_inside_result_block(current_line, file_enum)
258
+ @output_lines << current_line
259
+ if is_block_end?(current_line)
260
+ @state = :outside_code_block
261
+ end
316
262
  end
317
263
 
318
- puts "Running tests..."
319
- require "minitest/autorun"
264
+ def handle_existing_ruby_result_block(current_line, file_enum)
265
+ warn "Found existing '```ruby RESULT' block, passing through."
266
+ @output_lines << current_line
267
+ @state = :inside_result_block
268
+ end
320
269
 
321
- # --- Minitest Test Class Definition ---
322
- class TestMarkdownExec < Minitest::Test
323
- def setup
324
- @temp_dir = Dir.mktmpdir("markdown_exec_tests")
325
- @test_md_file_path = File.join(@temp_dir, "test.md")
270
+ def start_code_block(current_line, lang, options_string = nil)
271
+ @output_lines << current_line
272
+ @current_block_lang = resolve_language(lang)
273
+ @current_block_rerun = parse_rerun_option(options_string)
274
+ @state = :inside_code_block
275
+ @current_code_content = ""
326
276
  end
327
277
 
328
- def teardown
329
- FileUtils.remove_entry @temp_dir if @temp_dir && Dir.exist?(@temp_dir)
278
+ def accumulate_code_content(current_line)
279
+ @current_code_content += current_line
280
+ @output_lines << current_line
330
281
  end
331
282
 
332
- def create_md_file(content)
333
- File.write(@test_md_file_path, content)
334
- @test_md_file_path
283
+ def end_code_block(current_line, file_enum)
284
+ @output_lines << current_line
285
+
286
+ decision = decide_execution(file_enum)
287
+
288
+ if decision[:execute]
289
+ # If we consumed lines for rerun, don't add them to output (they'll be replaced)
290
+ execute_and_add_result(decision[:blank_line])
291
+ else
292
+ skip_and_pass_through_result(decision[:lines_to_pass_through], file_enum)
293
+ end
294
+
295
+ reset_code_block_state
335
296
  end
336
297
 
337
- def read_md_file
338
- File.read(@test_md_file_path)
298
+ def decide_execution(file_enum)
299
+ peek1 = peek_next_line(file_enum)
300
+ expected_header_regex = result_block_regex(@current_block_lang)
301
+
302
+ if line_matches_pattern?(peek1, expected_header_regex)
303
+ # If rerun=true, execute even if result block exists
304
+ if @current_block_rerun
305
+ # Consume the existing result block and execute
306
+ consumed_lines = [file_enum.next]
307
+ consume_existing_result_block(file_enum, consumed_lines)
308
+ return { execute: true, consumed_lines: consumed_lines }
309
+ else
310
+ return { execute: false, lines_to_pass_through: [file_enum.next] }
311
+ end
312
+ elsif is_blank_line?(peek1)
313
+ consumed_blank_line = file_enum.next
314
+ peek2 = peek_next_line(file_enum)
315
+
316
+ if line_matches_pattern?(peek2, expected_header_regex)
317
+ if @current_block_rerun
318
+ # Consume the blank line and existing result block, then execute
319
+ consumed_lines = [consumed_blank_line, file_enum.next]
320
+ consume_existing_result_block(file_enum, consumed_lines)
321
+ return { execute: true, consumed_lines: consumed_lines, blank_line: consumed_blank_line }
322
+ else
323
+ return { execute: false, lines_to_pass_through: [consumed_blank_line, file_enum.next] }
324
+ end
325
+ else
326
+ return { execute: true, blank_line: consumed_blank_line }
327
+ end
328
+ else
329
+ return { execute: true }
330
+ end
339
331
  end
340
332
 
341
- def test_script_runs_without_error_on_empty_file
342
- create_md_file("")
343
- assert process_markdown_file_main(@test_md_file_path), "Processing empty file should succeed"
344
- assert_equal "", read_md_file.strip, "Empty file should remain empty after processing"
333
+ def execute_and_add_result(blank_line_before_new_result)
334
+ @output_lines << blank_line_before_new_result if blank_line_before_new_result
335
+
336
+ if has_content?(@current_code_content)
337
+ result_output = execute_code_block(@current_code_content, @current_block_lang, @temp_dir)
338
+ add_result_block(result_output, blank_line_before_new_result)
339
+ else
340
+ warn "Skipping empty code block for language '#{@current_block_lang}'."
341
+ end
345
342
  end
346
343
 
347
- def test_psql_block_execution
348
- skip "Skipping test_psql_block_execution on GitHub CI" if ENV['CI']
344
+ def skip_and_pass_through_result(lines_to_pass_through, file_enum)
345
+ lang_specific_result_type = ruby_style_result?(@current_block_lang) ? "```ruby RESULT" : "```RESULT"
349
346
 
350
- md_content = <<~MARKDOWN
351
- ```psql
352
- SELECT 'hello psql test';
353
- ```
354
- MARKDOWN
355
- create_md_file(md_content)
356
- process_markdown_file_main(@test_md_file_path)
347
+ warn "Found existing '#{lang_specific_result_type}' block for current #{@current_block_lang} block, skipping execution."
357
348
 
358
- expected_output = <<~MARKDOWN.strip
359
- ```psql
360
- SELECT 'hello psql test';
361
- ```
349
+ @output_lines.concat(lines_to_pass_through)
362
350
 
363
- ```RESULT
364
- hello psql test
365
- ```
366
- MARKDOWN
367
- assert_equal expected_output, read_md_file.strip
351
+ consume_result_block_content(file_enum)
368
352
  end
369
353
 
370
- def test_ruby_block_execution_and_result_generation
371
- md_content = <<~MARKDOWN
372
- ```ruby
373
- puts "Hello from Ruby"
374
- p 1 + 2
375
- ```
376
- MARKDOWN
377
- create_md_file(md_content)
378
- process_markdown_file_main(@test_md_file_path)
379
-
380
- file_content = read_md_file
381
- assert file_content.include?("```ruby\nputs \"Hello from Ruby\""), "Original Ruby code should be present"
382
- assert file_content.include?("```ruby RESULT\n"), "Ruby RESULT block should be created"
383
- assert file_content.include?("3"), "Output from p 1 + 2 should be in the result"
354
+ def consume_result_block_content(file_enum)
355
+ begin
356
+ loop do
357
+ result_block_line = file_enum.next
358
+ @output_lines << result_block_line
359
+ break if is_block_end?(result_block_line)
360
+ end
361
+ rescue StopIteration
362
+ warn "Warning: End of file reached inside a skipped 'RESULT' block."
363
+ end
384
364
  end
385
365
 
386
- def test_skip_execution_if_result_block_exists
387
- original_content = <<~MARKDOWN
388
- ```psql
389
- SELECT 'this should not run';
390
- ```
366
+ def consume_existing_result_block(file_enum, consumed_lines)
367
+ begin
368
+ loop do
369
+ result_block_line = file_enum.next
370
+ consumed_lines << result_block_line
371
+ break if is_block_end?(result_block_line)
372
+ end
373
+ rescue StopIteration
374
+ warn "Warning: End of file reached while consuming existing result block for rerun."
375
+ end
376
+ end
391
377
 
392
- ```RESULT
393
- pre-existing result
394
- ```
395
- MARKDOWN
396
- create_md_file(original_content)
397
- process_markdown_file_main(@test_md_file_path)
378
+ def reset_code_block_state
379
+ @state = :outside_code_block
380
+ @current_code_content = ""
381
+ @current_block_rerun = false
382
+ end
383
+ end
398
384
 
399
- assert_equal original_content.strip, read_md_file.strip, "Should not execute if RESULT block exists"
385
+ def process_markdown_file_main(input_file_path)
386
+ unless File.exist?(input_file_path) && File.readable?(input_file_path)
387
+ warn "Error: Input file '#{input_file_path}' not found or not readable."
388
+ return false # Indicate failure
400
389
  end
401
390
 
402
- def test_skip_execution_if_ruby_result_block_exists
403
- original_content = <<~MARKDOWN
404
- ```ruby
405
- puts "this should not run either"
406
- ```
391
+ temp_dir = File.dirname(File.expand_path(input_file_path))
392
+ file_enum = File.foreach(input_file_path, chomp: false).to_enum
407
393
 
408
- ```ruby RESULT
409
- this is a pre-existing ruby result
410
- ```
411
- MARKDOWN
412
- create_md_file(original_content)
413
- process_markdown_file_main(@test_md_file_path)
394
+ processor = MarkdownProcessor.new(temp_dir)
395
+ output_lines = processor.process_file(file_enum)
414
396
 
415
- assert_equal original_content.strip, read_md_file.strip, "Should not execute if ```ruby RESULT block exists"
397
+ # Write the modified content back to the input file
398
+ Tempfile.create([ "md_exec_out_", File.extname(input_file_path) ], temp_dir) do |temp_output_file|
399
+ temp_output_file.write(output_lines.join(""))
400
+ temp_output_file.close
401
+ begin
402
+ FileUtils.mv(temp_output_file.path, input_file_path)
403
+ rescue Errno::EACCES, Errno::EXDEV
404
+ warn "Atomic move failed. Falling back to copy and delete."
405
+ FileUtils.cp(temp_output_file.path, input_file_path)
406
+ FileUtils.rm_f(temp_output_file.path)
407
+ end
416
408
  end
409
+ warn "Markdown processing complete. Output written to #{input_file_path}"
410
+ true # Indicate success
417
411
  end
412
+
413
+ if ARGV.empty?
414
+ puts "Running tests..."
415
+ require_relative "../test_markdown_exec"
416
+ else
417
+ process_markdown_file_main(ARGV[0])
418
418
  end
419
419
 
420
- process_markdown_file_main(ARGV[0]) unless ARGV.empty?
@@ -0,0 +1,80 @@
1
+ JS_CONFIG = {
2
+ command: ->(_code_content, temp_file_path) {
3
+ # Check if bun is available
4
+ bun_exists = system("command -v bun > /dev/null 2>&1")
5
+ if bun_exists
6
+ [ "bun #{temp_file_path}", {} ]
7
+ else
8
+ # Fallback to node if bun is not available
9
+ [ "node #{temp_file_path}", {} ]
10
+ end
11
+ },
12
+ temp_file_suffix: ".js",
13
+ error_handling: :js_specific # For specific stderr appending on error
14
+ }.freeze
15
+
16
+ SQLITE_CONFIG = {
17
+ command: ->(code_content, temp_file_path) { [ "sqlite3 #{temp_file_path}", { stdin_data: code_content } ] },
18
+ temp_file_suffix: ".db" # Temp file is the database
19
+ }.freeze
20
+
21
+ SUPPORTED_LANGUAGES = {
22
+ "psql" => {
23
+ command: ->(code_content, _temp_file_path) {
24
+ psql_exists = system("command -v psql > /dev/null 2>&1")
25
+ unless psql_exists
26
+ abort "Error: psql command not found. Please install PostgreSQL or ensure psql is in your PATH."
27
+ end
28
+ [ "psql -A -t -X", { stdin_data: code_content } ]
29
+ }
30
+ },
31
+ "ruby" => {
32
+ command: ->(_code_content, temp_file_path) {
33
+ xmpfilter_exists = system("command -v xmpfilter > /dev/null 2>&1")
34
+ unless xmpfilter_exists
35
+ abort "Error: xmpfilter command not found. Please install xmpfilter or ensure it is in your PATH."
36
+ end
37
+ [ "xmpfilter #{temp_file_path}", {} ]
38
+ },
39
+ temp_file_suffix: ".rb",
40
+ result_block_type: "ruby" # For special '```ruby RESULT' blocks
41
+ },
42
+ "js" => JS_CONFIG,
43
+ "javascript" => JS_CONFIG, # Alias for js
44
+ "sql" => SQLITE_CONFIG,
45
+ "sqlite" => SQLITE_CONFIG,
46
+ "sqlite3" => SQLITE_CONFIG, # Alias for sqlite
47
+ "bash" => {
48
+ command: ->(_code_content, temp_file_path) {
49
+ bash_exists = system("command -v bash > /dev/null 2>&1")
50
+ unless bash_exists
51
+ abort "Error: bash command not found. Please ensure bash is in your PATH."
52
+ end
53
+ [ "bash #{temp_file_path}", {} ]
54
+ },
55
+ temp_file_suffix: ".sh"
56
+ },
57
+ "zsh" => {
58
+ command: ->(_code_content, temp_file_path) {
59
+ zsh_exists = system("command -v zsh > /dev/null 2>&1")
60
+ unless zsh_exists
61
+ abort "Error: zsh command not found. Please ensure zsh is in your PATH."
62
+ end
63
+ [ "zsh #{temp_file_path}", {} ]
64
+ },
65
+ temp_file_suffix: ".zsh"
66
+ },
67
+ "sh" => {
68
+ command: ->(_code_content, temp_file_path) {
69
+ sh_exists = system("command -v sh > /dev/null 2>&1")
70
+ unless sh_exists
71
+ abort "Error: sh command not found. Please ensure sh is in your PATH."
72
+ end
73
+ [ "sh #{temp_file_path}", {} ]
74
+ },
75
+ temp_file_suffix: ".sh"
76
+ }
77
+ }.freeze
78
+
79
+ LANGUAGE_REGEX_PART = SUPPORTED_LANGUAGES.keys.map { |lang| Regexp.escape(lang) }.join("|").freeze
80
+ CODE_BLOCK_START_REGEX = /^```(#{LANGUAGE_REGEX_PART})$/i
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Markdown
4
4
  module Run
5
- VERSION = "0.1.5"
5
+ VERSION = "0.1.7"
6
6
  end
7
7
  end
@@ -0,0 +1,204 @@
1
+ require "bundler/inline"
2
+ gemfile(true) do
3
+ source "https://rubygems.org"
4
+ gem "minitest", "5.25.5" # Specify the required version
5
+ gem "rcodetools"
6
+ end
7
+
8
+ require "minitest/test"
9
+ require "minitest/autorun"
10
+ require "fileutils"
11
+ require "tmpdir"
12
+
13
+ # --- Minitest Test Class Definition ---
14
+ class TestMarkdownExec < Minitest::Test
15
+ def setup
16
+ @temp_dir = Dir.mktmpdir("markdown_exec_tests")
17
+ @test_md_file_path = File.join(@temp_dir, "test.md")
18
+ end
19
+
20
+ def teardown
21
+ FileUtils.remove_entry @temp_dir if @temp_dir && Dir.exist?(@temp_dir)
22
+ end
23
+
24
+ def create_md_file(content)
25
+ File.write(@test_md_file_path, content)
26
+ @test_md_file_path
27
+ end
28
+
29
+ def read_md_file
30
+ File.read(@test_md_file_path)
31
+ end
32
+
33
+ def test_script_runs_without_error_on_empty_file
34
+ create_md_file("")
35
+ assert process_markdown_file_main(@test_md_file_path), "Processing empty file should succeed"
36
+ assert_equal "", read_md_file.strip, "Empty file should remain empty after processing"
37
+ end
38
+
39
+ def test_psql_block_execution
40
+ skip "Skipping test_psql_block_execution on GitHub CI" if ENV['CI']
41
+
42
+ md_content = <<~MARKDOWN
43
+ ```psql
44
+ SELECT 'hello psql test';
45
+ ```
46
+ MARKDOWN
47
+ create_md_file(md_content)
48
+ process_markdown_file_main(@test_md_file_path)
49
+
50
+ expected_output = <<~MARKDOWN.strip
51
+ ```psql
52
+ SELECT 'hello psql test';
53
+ ```
54
+
55
+ ```RESULT
56
+ hello psql test
57
+ ```
58
+ MARKDOWN
59
+ assert_equal expected_output, read_md_file.strip
60
+ end
61
+
62
+ def test_ruby_block_execution_and_result_generation
63
+ md_content = <<~MARKDOWN
64
+ ```ruby
65
+ puts "Hello from Ruby"
66
+ p 1 + 2
67
+ ```
68
+ MARKDOWN
69
+ create_md_file(md_content)
70
+ process_markdown_file_main(@test_md_file_path)
71
+
72
+ file_content = read_md_file
73
+ assert file_content.include?("```ruby\nputs \"Hello from Ruby\""), "Original Ruby code should be present"
74
+ assert file_content.include?("```ruby RESULT\n"), "Ruby RESULT block should be created"
75
+ assert file_content.include?("3"), "Output from p 1 + 2 should be in the result"
76
+ end
77
+
78
+ def test_skip_execution_if_result_block_exists
79
+ original_content = <<~MARKDOWN
80
+ ```psql
81
+ SELECT 'this should not run';
82
+ ```
83
+
84
+ ```RESULT
85
+ pre-existing result
86
+ ```
87
+ MARKDOWN
88
+ create_md_file(original_content)
89
+ process_markdown_file_main(@test_md_file_path)
90
+
91
+ assert_equal original_content.strip, read_md_file.strip, "Should not execute if RESULT block exists"
92
+ end
93
+
94
+ def test_skip_execution_if_ruby_result_block_exists
95
+ original_content = <<~MARKDOWN
96
+ ```ruby
97
+ puts "this should not run either"
98
+ ```
99
+
100
+ ```ruby RESULT
101
+ this is a pre-existing ruby result
102
+ ```
103
+ MARKDOWN
104
+ create_md_file(original_content)
105
+ process_markdown_file_main(@test_md_file_path)
106
+
107
+ assert_equal original_content.strip, read_md_file.strip, "Should not execute if ```ruby RESULT block exists"
108
+ end
109
+
110
+ def test_frontmatter_alias_functionality
111
+ skip "Skipping test_frontmatter_alias_functionality on GitHub CI" if ENV['CI']
112
+
113
+ md_content = <<~MARKDOWN
114
+ ---
115
+ markdown-run:
116
+ alias:
117
+ - sql: psql
118
+ ---
119
+
120
+ # Test Document
121
+
122
+ ```sql
123
+ SELECT 'aliased to psql' as test;
124
+ ```
125
+ MARKDOWN
126
+ create_md_file(md_content)
127
+ process_markdown_file_main(@test_md_file_path)
128
+
129
+ file_content = read_md_file
130
+ assert file_content.include?("```sql\nSELECT 'aliased to psql' as test;"), "Original SQL code should be present"
131
+ assert file_content.include?("```RESULT\n"), "RESULT block should be created for aliased language"
132
+ assert file_content.include?("aliased to psql"), "Output should contain the expected result"
133
+ end
134
+
135
+ def test_rerun_functionality
136
+ # Test 1: Default behavior (no rerun option) should skip existing result
137
+ md_content_with_result = <<~MARKDOWN
138
+ ```ruby
139
+ puts "Should not change: \#{Time.now.to_i}"
140
+ ```
141
+
142
+ ```ruby RESULT
143
+ Should not change: 999999999
144
+ ```
145
+ MARKDOWN
146
+ create_md_file(md_content_with_result)
147
+ process_markdown_file_main(@test_md_file_path)
148
+
149
+ file_content = read_md_file
150
+ assert file_content.include?("Should not change: 999999999"), "Default behavior should preserve existing result"
151
+ refute file_content.match?(/Should not change: (?!999999999)\d+/), "Default behavior should not generate new timestamp"
152
+
153
+ # Test 2: rerun=false should skip existing result
154
+ md_content_rerun_false = <<~MARKDOWN
155
+ ```ruby rerun=false
156
+ puts "Should not change either: \#{Time.now.to_i}"
157
+ ```
158
+
159
+ ```ruby RESULT
160
+ Should not change either: 888888888
161
+ ```
162
+ MARKDOWN
163
+ create_md_file(md_content_rerun_false)
164
+ process_markdown_file_main(@test_md_file_path)
165
+
166
+ file_content = read_md_file
167
+ assert file_content.include?("Should not change either: 888888888"), "rerun=false should preserve existing result"
168
+ refute file_content.match?(/Should not change either: (?!888888888)\d+/), "rerun=false should not generate new timestamp"
169
+
170
+ # Test 3: rerun=true should replace existing result
171
+ md_content_rerun_true = <<~MARKDOWN
172
+ ```ruby rerun=true
173
+ puts "Should change: \#{Time.now.to_i}"
174
+ ```
175
+
176
+ ```ruby RESULT
177
+ Should change: 777777777
178
+ ```
179
+ MARKDOWN
180
+ create_md_file(md_content_rerun_true)
181
+ process_markdown_file_main(@test_md_file_path)
182
+
183
+ file_content = read_md_file
184
+ refute file_content.include?("Should change: 777777777"), "rerun=true should replace existing result"
185
+ assert file_content.match?(/Should change: \d+/), "rerun=true should generate new result with actual timestamp"
186
+
187
+ # Test 4: rerun=true with blank line before result block
188
+ md_content_rerun_true_blank = <<~MARKDOWN
189
+ ```ruby rerun=true
190
+ puts "Should also change: \#{Time.now.to_i}"
191
+ ```
192
+
193
+ ```ruby RESULT
194
+ Should also change: 666666666
195
+ ```
196
+ MARKDOWN
197
+ create_md_file(md_content_rerun_true_blank)
198
+ process_markdown_file_main(@test_md_file_path)
199
+
200
+ file_content = read_md_file
201
+ refute file_content.include?("Should also change: 666666666"), "rerun=true with blank line should replace existing result"
202
+ assert file_content.match?(/Should also change: \d+/), "rerun=true with blank line should generate new result"
203
+ end
204
+ 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.5
4
+ version: 0.1.7
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-05-19 00:00:00.000000000 Z
11
+ date: 2025-06-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rcodetools
@@ -53,13 +53,15 @@ files:
53
53
  - LICENSE.txt
54
54
  - README.md
55
55
  - exe/markdown-run
56
+ - lib/language_configs.rb
56
57
  - lib/markdown/run/version.rb
57
58
  - markdown-run-sample.md
58
- homepage: https://rubygems.org/gems/markdown-run
59
+ - test_markdown_exec.rb
60
+ homepage: https://github.com/aurelienbottazini/markdown-run
59
61
  licenses:
60
62
  - MIT
61
63
  metadata:
62
- homepage_uri: https://rubygems.org/gems/markdown-run
64
+ homepage_uri: https://github.com/aurelienbottazini/markdown-run
63
65
  source_code_uri: https://github.com/aurelienbottazini/markdown-run
64
66
  changelog_uri: https://github.com/aurelienbottazini/markdown-run/blob/main/CHANGELOG.md
65
67
  post_install_message: