markdown-run 0.1.11 → 0.2.0

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.
@@ -3,17 +3,23 @@ require_relative "enum_helper"
3
3
  class ExecutionDecider
4
4
  include EnumHelper
5
5
 
6
- def initialize(current_block_run, current_block_rerun, current_block_lang, current_block_explain = false, current_block_result = true)
6
+ def initialize(current_block_run, current_block_rerun, current_block_lang, current_block_explain = false, current_block_flamegraph = false, current_block_result = true)
7
7
  @current_block_run = current_block_run
8
8
  @current_block_rerun = current_block_rerun
9
9
  @current_block_lang = current_block_lang
10
10
  @current_block_explain = current_block_explain
11
+ @current_block_flamegraph = current_block_flamegraph
11
12
  @current_block_result = current_block_result
12
13
  end
13
14
 
14
- def decide(file_enum, result_block_regex_method)
15
+ def decide(file_enum, result_block_regex_method, code_content = nil)
15
16
  return skip_execution_run_false if run_disabled?
16
17
 
18
+ # For ruby blocks, check if code content contains xmpfilter results (# >>)
19
+ if is_ruby_block? && code_content && has_xmpfilter_results?(code_content)
20
+ return handle_inline_ruby_results
21
+ end
22
+
17
23
  expected_header_regex = result_block_regex_method.call(@current_block_lang)
18
24
  peek1 = peek_next_line(file_enum)
19
25
 
@@ -21,8 +27,10 @@ class ExecutionDecider
21
27
  handle_immediate_result_block(file_enum)
22
28
  elsif is_blank_line?(peek1)
23
29
  handle_blank_line_scenario(file_enum, expected_header_regex)
24
- elsif @current_block_explain && is_dalibo_link?(peek1)
30
+ elsif (@current_block_explain || @current_block_flamegraph) && is_dalibo_link?(peek1)
25
31
  handle_immediate_dalibo_link(file_enum)
32
+ elsif @current_block_flamegraph && is_flamegraph_link?(peek1)
33
+ handle_immediate_flamegraph_link(file_enum)
26
34
  else
27
35
  execute_without_existing_result
28
36
  end
@@ -48,11 +56,11 @@ class ExecutionDecider
48
56
 
49
57
  def handle_blank_line_scenario(file_enum, expected_header_regex)
50
58
  consumed_blank_line = file_enum.next
51
-
59
+
52
60
  # Look ahead past multiple blank lines to find actual content
53
61
  peek2 = peek_next_line(file_enum)
54
62
  additional_blanks = []
55
-
63
+
56
64
  # Consume consecutive blank lines
57
65
  while is_blank_line?(peek2)
58
66
  additional_blanks << file_enum.next
@@ -61,8 +69,10 @@ class ExecutionDecider
61
69
 
62
70
  if line_matches_pattern?(peek2, expected_header_regex)
63
71
  handle_result_after_blank_lines(file_enum, consumed_blank_line, additional_blanks)
64
- elsif @current_block_explain && is_dalibo_link?(peek2)
72
+ elsif (@current_block_explain || @current_block_flamegraph) && is_dalibo_link?(peek2)
65
73
  handle_dalibo_after_blank_lines(file_enum, consumed_blank_line, additional_blanks)
74
+ elsif @current_block_flamegraph && is_flamegraph_link?(peek2)
75
+ handle_flamegraph_after_blank_lines(file_enum, consumed_blank_line, additional_blanks)
66
76
  else
67
77
  execute_with_blank_lines(consumed_blank_line, additional_blanks)
68
78
  end
@@ -194,7 +204,7 @@ class ExecutionDecider
194
204
  # Consume all consecutive Dalibo links and blank lines
195
205
  loop do
196
206
  next_line = peek_next_line(file_enum)
197
-
207
+
198
208
  if is_blank_line?(next_line) || is_dalibo_link?(next_line)
199
209
  consumed_line = file_enum.next
200
210
  consumed_lines << consumed_line
@@ -205,7 +215,7 @@ class ExecutionDecider
205
215
  end
206
216
 
207
217
  def is_dalibo_link?(line)
208
- line&.start_with?("**Dalibo Visualization:**")
218
+ line&.start_with?("[Dalibo]")
209
219
  end
210
220
 
211
221
  def line_matches_pattern?(line, pattern)
@@ -217,9 +227,97 @@ class ExecutionDecider
217
227
  end
218
228
 
219
229
  def should_auto_replace_dalibo_link?
220
- # Auto-replace Dalibo links when using explain with result=false
230
+ # Auto-replace Dalibo links when using explain or flamegraph with result=false
221
231
  # This makes sense because with result=false, there's only a Dalibo link,
222
232
  # so it should be updated on each run
223
- @current_block_explain && !@current_block_result
233
+ (@current_block_explain || @current_block_flamegraph) && !@current_block_result
234
+ end
235
+
236
+ def is_flamegraph_link?(line)
237
+ line&.start_with?("![PostgreSQL Query Flamegraph]")
238
+ end
239
+
240
+ def handle_flamegraph_after_blank_lines(file_enum, consumed_blank_line, additional_blanks)
241
+ # For flamegraph result=false, always replace existing flamegraph links
242
+ # For flamegraph result=true, follow normal rerun logic
243
+ if should_auto_replace_flamegraph_link? || @current_block_rerun
244
+ execute_with_consumed_flamegraph_and_blanks(file_enum, consumed_blank_line, additional_blanks)
245
+ else
246
+ skip_with_blanks_and_flamegraph(file_enum, consumed_blank_line, additional_blanks)
247
+ end
248
+ end
249
+
250
+ def handle_immediate_flamegraph_link(file_enum)
251
+ # For flamegraph result=false, always replace existing flamegraph links
252
+ # For flamegraph result=true, follow normal rerun logic
253
+ if should_auto_replace_flamegraph_link? || @current_block_rerun
254
+ execute_with_consumed_flamegraph(file_enum)
255
+ else
256
+ skip_with_existing_flamegraph(file_enum)
257
+ end
258
+ end
259
+
260
+ def execute_with_consumed_flamegraph(file_enum)
261
+ consumed_lines = []
262
+ consume_flamegraph_links(file_enum, consumed_lines)
263
+ { execute: true, consumed_lines: consumed_lines, consume_existing_flamegraph: true }
264
+ end
265
+
266
+ def skip_with_existing_flamegraph(file_enum)
267
+ consumed_lines = []
268
+ consume_flamegraph_links(file_enum, consumed_lines)
269
+ { execute: false, lines_to_pass_through: consumed_lines, flamegraph_content: true }
270
+ end
271
+
272
+ def execute_with_consumed_flamegraph_and_blanks(file_enum, consumed_blank_line, additional_blanks)
273
+ consumed_lines = [consumed_blank_line] + additional_blanks
274
+ consume_flamegraph_links(file_enum, consumed_lines)
275
+ { execute: true, consumed_lines: consumed_lines, blank_line: consumed_blank_line, consume_existing_flamegraph: true }
276
+ end
277
+
278
+ def skip_with_blanks_and_flamegraph(file_enum, consumed_blank_line, additional_blanks)
279
+ consumed_lines = [consumed_blank_line] + additional_blanks
280
+ consume_flamegraph_links(file_enum, consumed_lines)
281
+ { execute: false, lines_to_pass_through: consumed_lines, flamegraph_content: true }
282
+ end
283
+
284
+ def consume_flamegraph_links(file_enum, consumed_lines)
285
+ # Consume all consecutive flamegraph links and blank lines
286
+ loop do
287
+ next_line = peek_next_line(file_enum)
288
+
289
+ if is_blank_line?(next_line) || is_flamegraph_link?(next_line)
290
+ consumed_line = file_enum.next
291
+ consumed_lines << consumed_line
292
+ else
293
+ break
294
+ end
295
+ end
296
+ end
297
+
298
+ def should_auto_replace_flamegraph_link?
299
+ # Auto-replace flamegraph links when using flamegraph with result=false
300
+ # This makes sense because with result=false, there's only a flamegraph link,
301
+ # so it should be updated on each run
302
+ @current_block_flamegraph && !@current_block_result
303
+ end
304
+
305
+ def is_ruby_block?
306
+ @current_block_lang == "ruby"
307
+ end
308
+
309
+ def has_xmpfilter_results?(code_content)
310
+ # Check if code contains xmpfilter comment markers (# >>)
311
+ code_content.include?("# >>")
312
+ end
313
+
314
+ def handle_inline_ruby_results
315
+ if @current_block_rerun
316
+ # Rerun requested, so execute and replace inline results
317
+ { execute: true }
318
+ else
319
+ # Has inline results and rerun not requested, skip execution
320
+ { execute: false, lines_to_pass_through: [] }
321
+ end
224
322
  end
225
323
  end
@@ -0,0 +1,39 @@
1
+ module FlamegraphHelper
2
+ private
3
+
4
+ FLAMEGRAPH_LINK_PREFIX = "FLAMEGRAPH_LINK:"
5
+
6
+ def extract_flamegraph_link(result_output)
7
+ # Check if the result contains a flamegraph link marker
8
+ if result_output.start_with?(FLAMEGRAPH_LINK_PREFIX)
9
+ lines = result_output.split("\n", 2)
10
+ flamegraph_path = lines[0].sub(FLAMEGRAPH_LINK_PREFIX, "")
11
+ clean_result = lines[1] || ""
12
+ flamegraph_link = "![PostgreSQL Query Flamegraph](#{flamegraph_path})"
13
+ [flamegraph_link, clean_result]
14
+ else
15
+ [nil, result_output]
16
+ end
17
+ end
18
+
19
+ def consume_flamegraph_link_if_present(file_enum, consumed_lines)
20
+ # Look ahead to see if there are flamegraph links after the result block
21
+ begin
22
+ # Keep consuming blank lines and flamegraph links until we hit something else
23
+ loop do
24
+ next_line = peek_next_line(file_enum)
25
+
26
+ if is_blank_line?(next_line)
27
+ consumed_lines << file_enum.next
28
+ elsif next_line&.start_with?("![PostgreSQL Query Flamegraph]")
29
+ consumed_lines << file_enum.next
30
+ else
31
+ # Hit something that's not a blank line or flamegraph link, stop consuming
32
+ break
33
+ end
34
+ end
35
+ rescue StopIteration
36
+ # End of file reached, nothing more to consume
37
+ end
38
+ end
39
+ end
@@ -1,16 +1,22 @@
1
1
  require "yaml"
2
2
  require_relative "enum_helper"
3
+ require_relative "language_resolver"
4
+ require_relative "test_silencer"
3
5
 
4
6
  class FrontmatterParser
5
7
  include EnumHelper
6
8
 
7
- def initialize
8
- @aliases = {}
9
+ def initialize(language_resolver = nil)
10
+ @language_resolver = language_resolver || LanguageResolver.new
9
11
  @defaults = {}
10
12
  @language_defaults = {}
11
13
  end
12
14
 
13
- attr_reader :aliases, :defaults, :language_defaults
15
+ attr_reader :defaults, :language_defaults
16
+
17
+ def language_resolver
18
+ @language_resolver
19
+ end
14
20
 
15
21
  def parse_frontmatter(file_enum, output_lines)
16
22
  first_line = peek_next_line(file_enum)
@@ -21,7 +27,7 @@ class FrontmatterParser
21
27
  end
22
28
 
23
29
  def resolve_language(lang)
24
- @aliases[lang] || lang
30
+ @language_resolver.resolve_language(lang)
25
31
  end
26
32
 
27
33
  def get_default_value(option_name, language, fallback_default)
@@ -72,7 +78,7 @@ class FrontmatterParser
72
78
  frontmatter = YAML.safe_load(frontmatter_lines.join)
73
79
  extract_aliases(frontmatter) if frontmatter.is_a?(Hash)
74
80
  rescue YAML::SyntaxError => e
75
- warn "Warning: Invalid YAML frontmatter: #{e.message}"
81
+ TestSilencer.warn_unless_testing "Warning: Invalid YAML frontmatter: #{e.message}"
76
82
  end
77
83
  end
78
84
 
@@ -83,13 +89,15 @@ class FrontmatterParser
83
89
  # Extract aliases
84
90
  aliases = markdown_run_config["alias"]
85
91
  if aliases.is_a?(Array)
92
+ new_aliases = {}
86
93
  aliases.each do |alias_config|
87
94
  next unless alias_config.is_a?(Hash)
88
95
 
89
96
  alias_config.each do |alias_name, target_lang|
90
- @aliases[alias_name.to_s] = target_lang.to_s
97
+ new_aliases[alias_name.to_s] = target_lang.to_s
91
98
  end
92
99
  end
100
+ @language_resolver.update_aliases(new_aliases)
93
101
  end
94
102
 
95
103
  # Extract defaults
@@ -1,7 +1,9 @@
1
- require 'securerandom'
1
+ require "securerandom"
2
+ require_relative "test_silencer"
3
+ require_relative "postgres_helper"
2
4
 
3
5
  JS_CONFIG = {
4
- command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
6
+ command: proc { |temp_file_path: nil, **|
5
7
  # Check if bun is available
6
8
  bun_exists = system("command -v bun > /dev/null 2>&1")
7
9
  if bun_exists
@@ -16,35 +18,41 @@ JS_CONFIG = {
16
18
  }.freeze
17
19
 
18
20
  SQLITE_CONFIG = {
19
- command: ->(code_content, temp_file_path, input_file_path = nil, explain = false) { [ "sqlite3 #{temp_file_path}", { stdin_data: code_content } ] },
21
+ command: proc { |code_content: nil, temp_file_path: nil, **| [ "sqlite3 #{temp_file_path}", { stdin_data: code_content } ] },
20
22
  temp_file_suffix: ".db" # Temp file is the database
21
23
  }.freeze
22
24
 
23
25
  SUPPORTED_LANGUAGES = {
24
26
  "psql" => {
25
- command: ->(code_content, _temp_file_path, input_file_path = nil, explain = false) {
26
- psql_exists = system("command -v psql > /dev/null 2>&1")
27
- unless psql_exists
28
- abort "Error: psql command not found. Please install PostgreSQL or ensure psql is in your PATH."
27
+ command: proc { |code_content: nil, explain: false, flamegraph: false, **|
28
+ unless PostgresHelper.available?
29
+ if PostgresHelper.psql_command && PostgresHelper.using_docker?
30
+ TestSilencer.abort_unless_testing "Error: PostgreSQL is running in Docker but required environment variables (PGUSER, PGDATABASE) are not set. Please set these variables before running psql commands."
31
+ else
32
+ TestSilencer.abort_unless_testing "Error: psql command not found. Please install PostgreSQL locally, ensure psql is in your PATH, or run PostgreSQL in a Docker container."
33
+ end
29
34
  end
30
35
 
31
- # Modify the SQL query if explain option is enabled
32
- if explain
36
+ PostgresHelper.validate_env_vars!
37
+ psql_cmd = PostgresHelper.psql_command
38
+
39
+ # Modify the SQL query if explain or flamegraph option is enabled
40
+ if explain || flamegraph
33
41
  # Wrap the query with EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
34
42
  # Remove any trailing semicolons and whitespace, then add our EXPLAIN wrapper
35
- clean_query = code_content.strip.gsub(/;\s*$/, '')
43
+ clean_query = code_content.strip.gsub(/;\s*$/, "")
36
44
  explained_query = "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) #{clean_query};"
37
- [ "psql -A -t -X", { stdin_data: explained_query } ]
45
+ [ "#{psql_cmd} -A -t -X", { stdin_data: explained_query } ]
38
46
  else
39
- [ "psql -A -t -X", { stdin_data: code_content } ]
47
+ [ "#{psql_cmd} -A -t -X", { stdin_data: code_content } ]
40
48
  end
41
49
  }
42
50
  },
43
51
  "ruby" => {
44
- command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
52
+ command: proc { |temp_file_path: nil, **|
45
53
  xmpfilter_exists = system("command -v xmpfilter > /dev/null 2>&1")
46
54
  unless xmpfilter_exists
47
- abort "Error: xmpfilter command not found. Please install xmpfilter or ensure it is in your PATH."
55
+ TestSilencer.abort_unless_testing "Error: xmpfilter command not found. Please install xmpfilter or ensure it is in your PATH."
48
56
  end
49
57
  [ "xmpfilter #{temp_file_path}", {} ]
50
58
  },
@@ -57,40 +65,40 @@ SUPPORTED_LANGUAGES = {
57
65
  "sqlite" => SQLITE_CONFIG,
58
66
  "sqlite3" => SQLITE_CONFIG, # Alias for sqlite
59
67
  "bash" => {
60
- command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
68
+ command: proc { |temp_file_path: nil, **|
61
69
  bash_exists = system("command -v bash > /dev/null 2>&1")
62
70
  unless bash_exists
63
- abort "Error: bash command not found. Please ensure bash is in your PATH."
71
+ TestSilencer.abort_unless_testing "Error: bash command not found. Please ensure bash is in your PATH."
64
72
  end
65
73
  [ "bash #{temp_file_path}", {} ]
66
74
  },
67
75
  temp_file_suffix: ".sh"
68
76
  },
69
77
  "zsh" => {
70
- command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
78
+ command: proc { |temp_file_path: nil, **|
71
79
  zsh_exists = system("command -v zsh > /dev/null 2>&1")
72
80
  unless zsh_exists
73
- abort "Error: zsh command not found. Please ensure zsh is in your PATH."
81
+ TestSilencer.abort_unless_testing "Error: zsh command not found. Please ensure zsh is in your PATH."
74
82
  end
75
83
  [ "zsh #{temp_file_path}", {} ]
76
84
  },
77
85
  temp_file_suffix: ".zsh"
78
86
  },
79
87
  "sh" => {
80
- command: ->(_code_content, temp_file_path, input_file_path = nil, explain = false) {
88
+ command: proc { |temp_file_path: nil, **|
81
89
  sh_exists = system("command -v sh > /dev/null 2>&1")
82
90
  unless sh_exists
83
- abort "Error: sh command not found. Please ensure sh is in your PATH."
91
+ TestSilencer.abort_unless_testing "Error: sh command not found. Please ensure sh is in your PATH."
84
92
  end
85
93
  [ "sh #{temp_file_path}", {} ]
86
94
  },
87
95
  temp_file_suffix: ".sh"
88
96
  },
89
97
  "mermaid" => {
90
- command: ->(code_content, temp_file_path, input_file_path = nil, explain = false) {
98
+ command: proc { |temp_file_path: nil, input_file_path: nil, **|
91
99
  mmdc_exists = system("command -v mmdc > /dev/null 2>&1")
92
100
  unless mmdc_exists
93
- abort "Error: mmdc command not found. Please install @mermaid-js/mermaid-cli: npm install -g @mermaid-js/mermaid-cli"
101
+ TestSilencer.abort_unless_testing "Error: mmdc command not found. Please install @mermaid-js/mermaid-cli: npm install -g @mermaid-js/mermaid-cli"
94
102
  end
95
103
 
96
104
  # Generate SVG output file path with directory structure based on markdown file
@@ -0,0 +1,17 @@
1
+ class LanguageResolver
2
+ def initialize(aliases = {})
3
+ @aliases = aliases
4
+ end
5
+
6
+ def resolve_language(lang)
7
+ @aliases[lang] || lang
8
+ end
9
+
10
+ def update_aliases(new_aliases)
11
+ @aliases.merge!(new_aliases)
12
+ end
13
+
14
+ def get_aliases
15
+ @aliases.dup
16
+ end
17
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Markdown
4
4
  module Run
5
- VERSION = "0.1.11"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -1,7 +1,9 @@
1
1
  require "tempfile"
2
2
  require "fileutils"
3
+ require_relative "test_silencer"
3
4
 
4
5
  class MarkdownFileWriter
6
+
5
7
  def self.write_output_to_file(output_lines, input_file_path)
6
8
  temp_dir = File.dirname(File.expand_path(input_file_path))
7
9
 
@@ -13,13 +15,14 @@ class MarkdownFileWriter
13
15
  begin
14
16
  FileUtils.mv(temp_output_file.path, input_file_path)
15
17
  rescue Errno::EACCES, Errno::EXDEV
16
- warn "Atomic move failed. Falling back to copy and delete."
18
+ TestSilencer.warn_unless_testing "Atomic move failed. Falling back to copy and delete."
17
19
  FileUtils.cp(temp_output_file.path, input_file_path)
18
20
  FileUtils.rm_f(temp_output_file.path)
19
21
  end
20
22
  end
21
23
 
22
- warn "Markdown processing complete. Output written to #{input_file_path}"
24
+ # Only show the warning message if we're not running tests
25
+ TestSilencer.warn_unless_testing("Markdown processing complete. Output written to #{input_file_path}")
23
26
  true # Indicate success
24
27
  end
25
28
  end
@@ -1,4 +1,5 @@
1
1
  require_relative "language_configs"
2
+ require_relative "language_resolver"
2
3
  require_relative "frontmatter_parser"
3
4
  require_relative "code_block_parser"
4
5
  require_relative "code_executor"
@@ -20,8 +21,9 @@ class MarkdownProcessor
20
21
  @output_lines = []
21
22
  reset_code_block_state
22
23
 
23
- @frontmatter_parser = FrontmatterParser.new
24
- @code_block_parser = CodeBlockParser.new(@frontmatter_parser)
24
+ @language_resolver = LanguageResolver.new
25
+ @frontmatter_parser = FrontmatterParser.new(@language_resolver)
26
+ @code_block_parser = CodeBlockParser.new(@frontmatter_parser, @language_resolver)
25
27
  end
26
28
 
27
29
  def process_file(file_enum)
@@ -38,9 +40,7 @@ class MarkdownProcessor
38
40
 
39
41
  private
40
42
 
41
- def resolve_language(lang)
42
- @frontmatter_parser.resolve_language(lang)
43
- end
43
+
44
44
 
45
45
  def is_block_end?(line)
46
46
  @code_block_parser.is_block_end?(line)