markdown-run 0.1.4 → 0.1.6

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: 00367c7c6c302db7b935780c6bb63f6deec1175516f82999ed7295f4b88794b6
4
- data.tar.gz: dbf713b531a435d794e40da3f9e4f2d04b9a78e859bedba44649ed0ac46a5898
3
+ metadata.gz: ac1a715b89896b392f75d2222bda1a4f5899dda9da7a12871548faee8d3c86bb
4
+ data.tar.gz: 5fd7954991346dd940594b264d5b162a91451ed7c89e7510eb6b0315d2c39195
5
5
  SHA512:
6
- metadata.gz: 5680e8dfa751cd59afc0cd5e92070cd6eb6d3d24c13a53f44212cc7f4398cd2504e3f63718c15f3e00ba92b8d87aced3a9d92804e9a25922b7af833c9d4608bb
7
- data.tar.gz: 5778ebed3f7495f478fdc370faa06c8b7c38ada306de254103849ee18a14251f94a281d0fd91d78207c79fe27bac125e4cdf24dd78c039085e0013b67796b898
6
+ metadata.gz: 1ee917248f47a0ccc7770fdd419fe3d65dfe2ca99c79d44efe0b46a9fd599761e0c218e622c39989c806de07f92480c0de1e4c7c869d7d9f7db2439a9fb70fd0
7
+ data.tar.gz: 2c3eeb35fee738e405c30bdbb5fee6e8cad6bb0acc599c1115ad77315179e28dd37c6aace1b125c5306d9702b2cbc4c2dec1394695c9908a73853917f73a1fa3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.6] - 2025-06-01
4
+
5
+ - Refactor code to state pattern
6
+ - Add yaml frontmatter to support aliases for code blocks
7
+
8
+ ## [0.1.5] - 2025-05-19
9
+
10
+ - Remove gif files from release
11
+
3
12
  ## [0.1.4] - 2025-05-18
4
13
 
5
14
  - Add support for zsh, bash, sh
data/README.md CHANGED
@@ -43,6 +43,18 @@ example vscode keybinding
43
43
  },
44
44
  ```
45
45
 
46
+ ## Frontmatter
47
+
48
+ You can add a yaml frontmatter to redefine code block behavior.
49
+ For example sql blocks run by default against sqlite
50
+ To have them run with postgres you can add at the top of your markdown file:
51
+
52
+ ```yaml
53
+ markdown-run:
54
+ alias:
55
+ - sql: psql
56
+ ```
57
+
46
58
  ## Demo
47
59
 
48
60
  ![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,307 @@ 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
+ @aliases = {}
163
92
  end
164
93
 
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
94
+ def process_file(file_enum)
95
+ parse_frontmatter(file_enum)
168
96
 
169
- in_code_block = false
170
- current_block_lang = ""
171
- current_code_content = ""
97
+ loop do
98
+ current_line = get_next_line(file_enum)
99
+ break unless current_line
172
100
 
173
- loop do
174
- current_line = nil
175
- begin
176
- current_line = file_enum.next
177
- rescue StopIteration
178
- break # End of file
101
+ handle_line(current_line, file_enum)
179
102
  end
103
+ @output_lines
104
+ end
180
105
 
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
208
- 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
-
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
216
-
217
- # Peek ahead logic
218
- peek1 = nil
219
- begin; peek1 = file_enum.peek; rescue StopIteration; end # If EOF, execute_this_block remains true
220
-
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"
223
-
224
- expected_header_regex = is_ruby_style_result ? /^```ruby\s+RESULT$/i : /^```RESULT$/i
225
-
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
231
-
232
- peek2 = nil
233
- begin; peek2 = file_enum.peek; rescue StopIteration; end # EOF after blank line
234
-
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
106
+ private
107
+
108
+ def parse_frontmatter(file_enum)
109
+ first_line = peek_next_line(file_enum)
110
+ return unless first_line&.strip == "---"
111
+
112
+ # Consume the opening ---
113
+ @output_lines << file_enum.next
114
+ frontmatter_lines = []
115
+
116
+ loop do
117
+ line = get_next_line(file_enum)
118
+ break unless line
119
+
120
+ if line.strip == "---"
121
+ @output_lines << line
122
+ break
279
123
  end
280
124
 
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
125
+ frontmatter_lines << line
126
+ @output_lines << line
289
127
  end
290
- end # loop
291
128
 
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
129
+ return if frontmatter_lines.empty?
130
+
296
131
  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)
132
+ frontmatter = YAML.safe_load(frontmatter_lines.join)
133
+ extract_aliases(frontmatter) if frontmatter.is_a?(Hash)
134
+ rescue YAML::SyntaxError => e
135
+ warn "Warning: Invalid YAML frontmatter: #{e.message}"
302
136
  end
303
137
  end
304
- warn "Markdown processing complete. Output written to #{input_file_path}"
305
- true # Indicate success
306
- end
307
138
 
308
- if ARGV.empty?
139
+ def extract_aliases(frontmatter)
140
+ markdown_run_config = frontmatter["markdown-run"]
141
+ return unless markdown_run_config.is_a?(Hash)
142
+
143
+ aliases = markdown_run_config["alias"]
144
+ return unless aliases.is_a?(Array)
145
+
146
+ aliases.each do |alias_config|
147
+ next unless alias_config.is_a?(Hash)
309
148
 
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"
149
+ alias_config.each do |alias_name, target_lang|
150
+ @aliases[alias_name.to_s] = target_lang.to_s
151
+ end
152
+ end
316
153
  end
317
154
 
318
- puts "Running tests..."
319
- require "minitest/autorun"
155
+ def resolve_language(lang)
156
+ @aliases[lang] || lang
157
+ end
320
158
 
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")
159
+ def ruby_style_result?(lang)
160
+ lang_config = SUPPORTED_LANGUAGES[lang]
161
+ lang_config && lang_config[:result_block_type] == "ruby"
326
162
  end
327
163
 
328
- def teardown
329
- FileUtils.remove_entry @temp_dir if @temp_dir && Dir.exist?(@temp_dir)
164
+ def result_block_header(lang)
165
+ ruby_style_result?(lang) ? "```ruby RESULT\n" : "```RESULT\n"
330
166
  end
331
167
 
332
- def create_md_file(content)
333
- File.write(@test_md_file_path, content)
334
- @test_md_file_path
168
+ def result_block_regex(lang)
169
+ ruby_style_result?(lang) ? /^```ruby\s+RESULT$/i : /^```RESULT$/i
335
170
  end
336
171
 
337
- def read_md_file
338
- File.read(@test_md_file_path)
172
+ def is_block_end?(line)
173
+ line.strip == "```"
339
174
  end
340
175
 
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"
176
+ def has_content?(content)
177
+ !content.strip.empty?
345
178
  end
346
179
 
347
- def test_psql_block_execution
348
- skip "Skipping test_psql_block_execution on GitHub CI" if ENV['CI']
180
+ def add_result_block(result_output, blank_line_before_new_result)
181
+ @output_lines << "\n" if blank_line_before_new_result.nil?
182
+ @output_lines << result_block_header(@current_block_lang)
183
+ @output_lines << result_output
184
+ @output_lines << "\n" unless result_output.empty? || result_output.end_with?("\n")
185
+ @output_lines << "```\n\n"
186
+ end
349
187
 
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)
188
+ def line_matches_pattern?(line, pattern)
189
+ line && line.match?(pattern)
190
+ end
357
191
 
358
- expected_output = <<~MARKDOWN.strip
359
- ```psql
360
- SELECT 'hello psql test';
361
- ```
192
+ def is_blank_line?(line)
193
+ line && line.strip == ""
194
+ end
362
195
 
363
- ```RESULT
364
- hello psql test
365
- ```
366
- MARKDOWN
367
- assert_equal expected_output, read_md_file.strip
196
+ def safe_enum_operation(file_enum, operation)
197
+ file_enum.send(operation)
198
+ rescue StopIteration
199
+ nil
368
200
  end
369
201
 
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)
202
+ def get_next_line(file_enum)
203
+ safe_enum_operation(file_enum, :next)
204
+ end
379
205
 
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"
206
+ def peek_next_line(file_enum)
207
+ safe_enum_operation(file_enum, :peek)
384
208
  end
385
209
 
386
- def test_skip_execution_if_result_block_exists
387
- original_content = <<~MARKDOWN
388
- ```psql
389
- SELECT 'this should not run';
390
- ```
210
+ def handle_line(current_line, file_enum)
211
+ case @state
212
+ when :outside_code_block
213
+ handle_outside_code_block(current_line, file_enum)
214
+ when :inside_code_block
215
+ handle_inside_code_block(current_line, file_enum)
216
+ when :inside_result_block
217
+ handle_inside_result_block(current_line, file_enum)
218
+ end
219
+ end
391
220
 
392
- ```RESULT
393
- pre-existing result
394
- ```
395
- MARKDOWN
396
- create_md_file(original_content)
397
- process_markdown_file_main(@test_md_file_path)
221
+ def handle_outside_code_block(current_line, file_enum)
222
+ if current_line.match?(/^```ruby\s+RESULT$/i)
223
+ handle_existing_ruby_result_block(current_line, file_enum)
224
+ elsif (match_data = current_line.match(/^```(\w+)$/i))
225
+ lang = match_data[1].downcase
226
+ resolved_lang = resolve_language(lang)
227
+ if SUPPORTED_LANGUAGES.key?(resolved_lang)
228
+ start_code_block(current_line, lang)
229
+ else
230
+ @output_lines << current_line
231
+ end
232
+ else
233
+ @output_lines << current_line
234
+ end
235
+ end
236
+
237
+ def handle_inside_code_block(current_line, file_enum)
238
+ if is_block_end?(current_line)
239
+ end_code_block(current_line, file_enum)
240
+ else
241
+ accumulate_code_content(current_line)
242
+ end
243
+ end
244
+
245
+ def handle_inside_result_block(current_line, file_enum)
246
+ @output_lines << current_line
247
+ if is_block_end?(current_line)
248
+ @state = :outside_code_block
249
+ end
250
+ end
251
+
252
+ def handle_existing_ruby_result_block(current_line, file_enum)
253
+ warn "Found existing '```ruby RESULT' block, passing through."
254
+ @output_lines << current_line
255
+ @state = :inside_result_block
256
+ end
257
+
258
+ def start_code_block(current_line, lang)
259
+ @output_lines << current_line
260
+ @current_block_lang = resolve_language(lang)
261
+ @state = :inside_code_block
262
+ @current_code_content = ""
263
+ end
264
+
265
+ def accumulate_code_content(current_line)
266
+ @current_code_content += current_line
267
+ @output_lines << current_line
268
+ end
269
+
270
+ def end_code_block(current_line, file_enum)
271
+ @output_lines << current_line
272
+
273
+ decision = decide_execution(file_enum)
274
+
275
+ if decision[:execute]
276
+ execute_and_add_result(decision[:blank_line])
277
+ else
278
+ skip_and_pass_through_result(decision[:lines_to_pass_through], file_enum)
279
+ end
280
+
281
+ reset_code_block_state
282
+ end
283
+
284
+ def decide_execution(file_enum)
285
+ peek1 = peek_next_line(file_enum)
286
+ expected_header_regex = result_block_regex(@current_block_lang)
287
+
288
+ if line_matches_pattern?(peek1, expected_header_regex)
289
+ return { execute: false, lines_to_pass_through: [file_enum.next] }
290
+ elsif is_blank_line?(peek1)
291
+ consumed_blank_line = file_enum.next
292
+ peek2 = peek_next_line(file_enum)
293
+
294
+ if line_matches_pattern?(peek2, expected_header_regex)
295
+ return { execute: false, lines_to_pass_through: [consumed_blank_line, file_enum.next] }
296
+ else
297
+ return { execute: true, blank_line: consumed_blank_line }
298
+ end
299
+ else
300
+ return { execute: true }
301
+ end
302
+ end
303
+
304
+ def execute_and_add_result(blank_line_before_new_result)
305
+ @output_lines << blank_line_before_new_result if blank_line_before_new_result
306
+
307
+ if has_content?(@current_code_content)
308
+ result_output = execute_code_block(@current_code_content, @current_block_lang, @temp_dir)
309
+ add_result_block(result_output, blank_line_before_new_result)
310
+ else
311
+ warn "Skipping empty code block for language '#{@current_block_lang}'."
312
+ end
313
+ end
314
+
315
+ def skip_and_pass_through_result(lines_to_pass_through, file_enum)
316
+ lang_specific_result_type = ruby_style_result?(@current_block_lang) ? "```ruby RESULT" : "```RESULT"
398
317
 
399
- assert_equal original_content.strip, read_md_file.strip, "Should not execute if RESULT block exists"
318
+ warn "Found existing '#{lang_specific_result_type}' block for current #{@current_block_lang} block, skipping execution."
319
+
320
+ @output_lines.concat(lines_to_pass_through)
321
+
322
+ consume_result_block_content(file_enum)
400
323
  end
401
324
 
402
- def test_skip_execution_if_ruby_result_block_exists
403
- original_content = <<~MARKDOWN
404
- ```ruby
405
- puts "this should not run either"
406
- ```
325
+ def consume_result_block_content(file_enum)
326
+ begin
327
+ loop do
328
+ result_block_line = file_enum.next
329
+ @output_lines << result_block_line
330
+ break if is_block_end?(result_block_line)
331
+ end
332
+ rescue StopIteration
333
+ warn "Warning: End of file reached inside a skipped 'RESULT' block."
334
+ end
335
+ end
407
336
 
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)
337
+ def reset_code_block_state
338
+ @state = :outside_code_block
339
+ @current_code_content = ""
340
+ end
341
+ end
414
342
 
415
- assert_equal original_content.strip, read_md_file.strip, "Should not execute if ```ruby RESULT block exists"
343
+ def process_markdown_file_main(input_file_path)
344
+ unless File.exist?(input_file_path) && File.readable?(input_file_path)
345
+ warn "Error: Input file '#{input_file_path}' not found or not readable."
346
+ return false # Indicate failure
416
347
  end
348
+
349
+ temp_dir = File.dirname(File.expand_path(input_file_path))
350
+ file_enum = File.foreach(input_file_path, chomp: false).to_enum
351
+
352
+ processor = MarkdownProcessor.new(temp_dir)
353
+ output_lines = processor.process_file(file_enum)
354
+
355
+ # Write the modified content back to the input file
356
+ Tempfile.create([ "md_exec_out_", File.extname(input_file_path) ], temp_dir) do |temp_output_file|
357
+ temp_output_file.write(output_lines.join(""))
358
+ temp_output_file.close
359
+ begin
360
+ FileUtils.mv(temp_output_file.path, input_file_path)
361
+ rescue Errno::EACCES, Errno::EXDEV
362
+ warn "Atomic move failed. Falling back to copy and delete."
363
+ FileUtils.cp(temp_output_file.path, input_file_path)
364
+ FileUtils.rm_f(temp_output_file.path)
365
+ end
366
+ end
367
+ warn "Markdown processing complete. Output written to #{input_file_path}"
368
+ true # Indicate success
417
369
  end
370
+
371
+ if ARGV.empty?
372
+ puts "Running tests..."
373
+ require_relative "../test_markdown_exec"
374
+ else
375
+ process_markdown_file_main(ARGV[0])
418
376
  end
419
377
 
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.4"
5
+ VERSION = "0.1.6"
6
6
  end
7
7
  end
@@ -0,0 +1,134 @@
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
+ 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
4
+ version: 0.1.6
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-18 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
@@ -52,10 +52,11 @@ files:
52
52
  - CODE_OF_CONDUCT.md
53
53
  - LICENSE.txt
54
54
  - README.md
55
- - docs/markdown-run-vscode.gif
56
55
  - exe/markdown-run
56
+ - lib/language_configs.rb
57
57
  - lib/markdown/run/version.rb
58
58
  - markdown-run-sample.md
59
+ - test_markdown_exec.rb
59
60
  homepage: https://rubygems.org/gems/markdown-run
60
61
  licenses:
61
62
  - MIT
Binary file