markdown_exec 1.3.8 → 1.4

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.
data/lib/markdown_exec.rb CHANGED
@@ -19,13 +19,13 @@ require_relative 'colorize'
19
19
  require_relative 'env'
20
20
  require_relative 'fcb'
21
21
  require_relative 'filter'
22
+ require_relative 'markdown_exec/version'
22
23
  require_relative 'mdoc'
23
24
  require_relative 'option_value'
24
25
  require_relative 'saved_assets'
25
26
  require_relative 'saved_files_matcher'
26
27
  require_relative 'shared'
27
28
  require_relative 'tap'
28
- require_relative 'markdown_exec/version'
29
29
 
30
30
  include CLI
31
31
  include Tap
@@ -35,7 +35,11 @@ tap_config envvar: MarkdownExec::TAP_DEBUG
35
35
  $stderr.sync = true
36
36
  $stdout.sync = true
37
37
 
38
- BLOCK_SIZE = 1024
38
+ MDE_HISTORY_ENV_NAME = 'MDE_MENU_HISTORY'
39
+
40
+ # macros
41
+ #
42
+ LOAD_FILE = true
39
43
 
40
44
  # custom error: file specified is missing
41
45
  #
@@ -58,6 +62,18 @@ class Hash
58
62
  end
59
63
  end
60
64
 
65
+ # integer value for comparison
66
+ #
67
+ def options_fetch_display_level(options)
68
+ options.fetch(:display_level, 1)
69
+ end
70
+
71
+ # integer value for comparison
72
+ #
73
+ def options_fetch_display_level_xbase_prefix(options)
74
+ options.fetch(:level_xbase_prefix, '')
75
+ end
76
+
61
77
  # stdout manager
62
78
  #
63
79
  module FOUT
@@ -77,7 +93,7 @@ module FOUT
77
93
  end
78
94
 
79
95
  def approved_fout?(level)
80
- level <= @options[:display_level]
96
+ level <= options_fetch_display_level(@options)
81
97
  end
82
98
 
83
99
  # display output at level or lower than filter (DISPLAY_LEVEL_DEFAULT)
@@ -85,7 +101,7 @@ module FOUT
85
101
  def lout(str, level: DISPLAY_LEVEL_BASE)
86
102
  return unless approved_fout? level
87
103
 
88
- fout level == DISPLAY_LEVEL_BASE ? str : @options[:display_level_xbase_prefix] + str
104
+ fout level == DISPLAY_LEVEL_BASE ? str : options_fetch_display_level_xbase_prefix(@options) + str
89
105
  end
90
106
  end
91
107
 
@@ -110,10 +126,45 @@ end
110
126
  # convert regex match groups to a hash with symbol keys
111
127
  #
112
128
  # :reek:UtilityFunction
113
- def option_match_groups(str, option)
129
+ def extract_named_captures_from_option(str, option)
114
130
  str.match(Regexp.new(option))&.named_captures&.sym_keys
115
131
  end
116
132
 
133
+ module ArrayUtil
134
+ def self.partition_by_predicate(arr)
135
+ true_list = []
136
+ false_list = []
137
+
138
+ arr.each do |element|
139
+ if yield(element)
140
+ true_list << element
141
+ else
142
+ false_list << element
143
+ end
144
+ end
145
+
146
+ [true_list, false_list]
147
+ end
148
+ end
149
+
150
+ module StringUtil
151
+ # Splits the given string on the first occurrence of the specified character.
152
+ # Returns an array containing the portion of the string before the character and the rest of the string.
153
+ #
154
+ # @param input_str [String] The string to be split.
155
+ # @param split_char [String] The character on which to split the string.
156
+ # @return [Array<String>] An array containing two elements: the part of the string before split_char, and the rest of the string.
157
+ def self.partition_at_first(input_str, split_char)
158
+ split_index = input_str.index(split_char)
159
+
160
+ if split_index.nil?
161
+ [input_str, '']
162
+ else
163
+ [input_str[0...split_index], input_str[(split_index + 1)..-1]]
164
+ end
165
+ end
166
+ end
167
+
117
168
  # execute markdown documents
118
169
  #
119
170
  module MarkdownExec
@@ -121,6 +172,13 @@ module MarkdownExec
121
172
  FNR11 = '/'
122
173
  FNR12 = ',~'
123
174
 
175
+ SHELL_COLOR_OPTIONS = {
176
+ BLOCK_TYPE_BASH => :menu_bash_color,
177
+ BLOCK_TYPE_LINK => :menu_link_color,
178
+ BLOCK_TYPE_OPTS => :menu_opts_color,
179
+ BLOCK_TYPE_VARS => :menu_vars_color
180
+ }.freeze
181
+
124
182
  ##
125
183
  #
126
184
  # rubocop:disable Layout/LineLength
@@ -132,12 +190,11 @@ module MarkdownExec
132
190
  class MarkParse
133
191
  attr_reader :options
134
192
 
193
+ include ArrayUtil
194
+ include StringUtil
135
195
  include FOUT
136
196
 
137
197
  def initialize(options = {})
138
- @options = options
139
- # hide disabled symbol
140
- @prompt = TTY::Prompt.new(interrupt: :exit, symbols: { cross: ' ' })
141
198
  @execute_aborted_at = nil
142
199
  @execute_completed_at = nil
143
200
  @execute_error = nil
@@ -147,6 +204,74 @@ module MarkdownExec
147
204
  @execute_script_filespec = nil
148
205
  @execute_started_at = nil
149
206
  @option_parser = nil
207
+ @options = options
208
+ @prompt = tty_prompt_without_disabled_symbol
209
+ end
210
+
211
+ ##
212
+ # Appends a summary of a block (FCB) to the blocks array.
213
+ #
214
+ def append_block_summary(blocks, fcb, opts)
215
+ ## enhance fcb with block summary
216
+ #
217
+ blocks.push get_block_summary(opts, fcb)
218
+ end
219
+
220
+ ##
221
+ # Appends a final divider to the blocks array if it is specified in options.
222
+ #
223
+ def append_final_divider(blocks, opts)
224
+ return unless opts[:menu_divider_format].present? && opts[:menu_final_divider].present?
225
+
226
+ blocks.push FCB.new(
227
+ { chrome: true,
228
+ disabled: '',
229
+ dname: format(opts[:menu_divider_format],
230
+ opts[:menu_final_divider])
231
+ .send(opts[:menu_divider_color].to_sym),
232
+ oname: opts[:menu_final_divider] }
233
+ )
234
+ end
235
+
236
+ ##
237
+ # Appends an initial divider to the blocks array if it is specified in options.
238
+ #
239
+ def append_initial_divider(blocks, opts)
240
+ return unless opts[:menu_initial_divider].present?
241
+
242
+ blocks.push FCB.new({
243
+ # name: '',
244
+ chrome: true,
245
+ dname: format(
246
+ opts[:menu_divider_format],
247
+ opts[:menu_initial_divider]
248
+ ).send(opts[:menu_divider_color].to_sym),
249
+ oname: opts[:menu_initial_divider],
250
+ disabled: '' # __LINE__.to_s
251
+ })
252
+ end
253
+
254
+ # Execute a code block after approval and provide user interaction options.
255
+ #
256
+ # This method displays required code blocks, asks for user approval, and
257
+ # executes the code block if approved. It also allows users to copy the
258
+ # code to the clipboard or save it to a file.
259
+ #
260
+ # @param opts [Hash] Options hash containing configuration settings.
261
+ # @param mdoc [YourMDocClass] An instance of the MDoc class.
262
+ #
263
+ def approve_and_execute_block(opts, mdoc)
264
+ selected = mdoc.get_block_by_name(opts[:block_name])
265
+
266
+ if selected.fetch(:shell, '') == BLOCK_TYPE_LINK
267
+ handle_shell_link(opts, selected.fetch(:body, ''), mdoc)
268
+ elsif opts.fetch(:back, false)
269
+ handle_back_link(opts)
270
+ elsif selected[:shell] == BLOCK_TYPE_OPTS
271
+ handle_shell_opts(opts, selected)
272
+ else
273
+ handle_remainder_blocks(mdoc, opts, selected)
274
+ end
150
275
  end
151
276
 
152
277
  # return arguments before `--`
@@ -162,17 +287,6 @@ module MarkdownExec
162
287
  end
163
288
  end
164
289
 
165
- # return arguments after `--`
166
- #
167
- def arguments_for_child(argv = ARGV)
168
- case ind = argv.find_index('--')
169
- when nil, argv.count - 1
170
- []
171
- else
172
- argv[ind + 1..-1]
173
- end
174
- end
175
-
176
290
  ##
177
291
  # options necessary to start, parse input, defaults for cli options
178
292
  #
@@ -191,6 +305,14 @@ module MarkdownExec
191
305
  end.compact.to_h
192
306
  end
193
307
 
308
+ def blocks_per_opts(blocks, opts)
309
+ return blocks if opts[:struct]
310
+
311
+ blocks.map do |block|
312
+ block.fetch(:text, nil) || block.oname
313
+ end.compact.reject(&:empty?)
314
+ end
315
+
194
316
  def calculated_options
195
317
  {
196
318
  bash: true, # bash block parsing in get_block_summary()
@@ -199,133 +321,84 @@ module MarkdownExec
199
321
  }
200
322
  end
201
323
 
202
- # Execute a code block after approval and provide user interaction options.
203
- #
204
- # This method displays required code blocks, asks for user approval, and
205
- # executes the code block if approved. It also allows users to copy the
206
- # code to the clipboard or save it to a file.
207
- #
208
- # @param opts [Hash] Options hash containing configuration settings.
209
- # @param mdoc [YourMDocClass] An instance of the MDoc class.
210
- # @return [String] The name of the executed code block.
211
- def approve_and_execute_block(opts, mdoc)
212
- # Collect required code blocks based on the provided options.
213
- required_blocks = mdoc.collect_recursively_required_code(opts[:block_name])
214
- # Display required code blocks if requested or required approval.
215
- if opts[:output_script] || opts[:user_must_approve]
216
- display_required_code(opts,
217
- required_blocks)
324
+ # Check whether the document exists and is readable
325
+ def check_file_existence(filename)
326
+ unless filename&.present?
327
+ fout 'No blocks found.'
328
+ return false
218
329
  end
219
330
 
220
- allow = true
221
- # If user approval is required, prompt the user for approval.
222
- if opts[:user_must_approve]
223
- loop do
224
- # Present a selection menu for user approval.
225
- sel = @prompt.select(opts[:prompt_approve_block],
226
- filter: true) do |menu|
227
- menu.default 1
228
- menu.choice opts[:prompt_yes], 1
229
- menu.choice opts[:prompt_no], 2
230
- menu.choice opts[:prompt_script_to_clipboard], 3
231
- menu.choice opts[:prompt_save_script], 4
232
- end
233
- allow = (sel == 1)
234
- if sel == 3
235
- # Copy the code to the clipboard.
236
- text = required_blocks.flatten.join($INPUT_RECORD_SEPARATOR)
237
- Clipboard.copy(text)
238
- fout "Clipboard updated: #{required_blocks.count} blocks," /
239
- " #{required_blocks.flatten.count} lines," /
240
- " #{text.length} characters"
241
- end
242
- if sel == 4
243
- # Save the code to a file.
244
- write_command_file(opts.merge(save_executed_script: true),
245
- required_blocks)
246
- fout "File saved: #{@options[:saved_filespec]}"
247
- end
248
- break if [1, 2].include? sel
249
- end
331
+ unless File.exist? filename
332
+ fout 'Document is missing.'
333
+ return false
250
334
  end
335
+ true
336
+ end
251
337
 
252
- opts[:ir_approve] = allow
253
-
254
- # Get the selected code block by name.
255
- selected = mdoc.get_block_by_name(opts[:block_name])
338
+ def clear_required_file
339
+ ENV['MDE_LINK_REQUIRED_FILE'] = ''
340
+ end
256
341
 
257
- # If approved, write the code to a file, execute it, and provide output.
258
- if opts[:ir_approve]
259
- write_command_file(opts, required_blocks)
260
- command_execute(opts, required_blocks.flatten.join("\n"))
261
- save_execution_output
262
- output_execution_summary
263
- output_execution_result
342
+ # Collect required code blocks based on the provided options.
343
+ #
344
+ # @param opts [Hash] Options hash containing configuration settings.
345
+ # @param mdoc [YourMDocClass] An instance of the MDoc class.
346
+ # @return [Array<String>] Required code blocks as an array of lines.
347
+ def collect_required_code_lines(mdoc, selected, opts: {})
348
+ # Apply hash in opts block to environment variables
349
+ if selected[:shell] == BLOCK_TYPE_VARS
350
+ data = YAML.load(selected[:body].join("\n"))
351
+ data.each_key do |key|
352
+ ENV[key] = value = data[key].to_s
353
+ next unless opts[:menu_vars_set_format].present?
354
+
355
+ print format(
356
+ opts[:menu_vars_set_format],
357
+ { key: key,
358
+ value: value }
359
+ ).send(opts[:menu_vars_set_color].to_sym)
360
+ end
264
361
  end
265
362
 
266
- selected[:name]
363
+ required = mdoc.collect_recursively_required_code(opts[:block_name], opts: opts)
364
+ read_required_blocks_from_temp_file + required[:code]
267
365
  end
268
366
 
269
367
  def cfile
270
- @cfile ||= CachedNestedFileReader.new(import_pattern: @options.fetch(:import_pattern))
368
+ @cfile ||= CachedNestedFileReader.new(
369
+ import_pattern: @options.fetch(:import_pattern)
370
+ )
271
371
  end
272
372
 
273
- # :reek:DuplicateMethodCall
274
- # :reek:UncommunicativeVariableName { exclude: [ e ] }
275
- # :reek:LongYieldList
276
- def command_execute(opts, command)
277
- #d 'execute command and yield outputs'
373
+ EF_STDOUT = :stdout
374
+ EF_STDERR = :stderr
375
+ EF_STDIN = :stdin
376
+
377
+ # Existing command_execute method
378
+ def command_execute(opts, command, args: [])
278
379
  @execute_files = Hash.new([])
279
380
  @execute_options = opts
280
381
  @execute_started_at = Time.now.utc
281
382
 
282
- args = []
283
- Open3.popen3(@options[:shell], '-c',
284
- command, ARGV[0], *args) do |stdin, stdout, stderr, exec_thr|
285
- #d 'command started'
286
- Thread.new do
287
- until (line = stdout.gets).nil?
288
- @execute_files[EF_STDOUT] = @execute_files[EF_STDOUT] + [line]
289
- print line if opts[:output_stdout]
290
- yield nil, line, nil, exec_thr if block_given?
291
- end
292
- rescue IOError
293
- #d 'stdout IOError, thread killed, do nothing'
383
+ Open3.popen3(opts[:shell], '-c', command, opts[:filename],
384
+ *args) do |stdin, stdout, stderr, exec_thr|
385
+ handle_stream(opts, stdout, EF_STDOUT) do |line|
386
+ yield nil, line, nil, exec_thr if block_given?
294
387
  end
295
-
296
- Thread.new do
297
- until (line = stderr.gets).nil?
298
- @execute_files[EF_STDERR] = @execute_files[EF_STDERR] + [line]
299
- print line if opts[:output_stdout]
300
- yield nil, nil, line, exec_thr if block_given?
301
- end
302
- rescue IOError
303
- #d 'stderr IOError, thread killed, do nothing'
388
+ handle_stream(opts, stderr, EF_STDERR) do |line|
389
+ yield nil, nil, line, exec_thr if block_given?
304
390
  end
305
391
 
306
- in_thr = Thread.new do
307
- while exec_thr.alive? # reading input until the child process ends
308
- stdin.puts(line = $stdin.gets)
309
- @execute_files[EF_STDIN] = @execute_files[EF_STDIN] + [line]
310
- yield line, nil, nil, exec_thr if block_given?
311
- end
312
- #d 'exec_thr now dead'
313
- rescue StandardError
314
- #d 'stdin error, thread killed, do nothing'
392
+ in_thr = handle_stream(opts, $stdin, EF_STDIN) do |line|
393
+ stdin.puts(line)
394
+ yield line, nil, nil, exec_thr if block_given?
315
395
  end
316
396
 
317
- #d 'join exec_thr'
318
397
  exec_thr.join
319
-
320
- #d 'wait before closing stdin'
321
398
  sleep 0.1
322
-
323
- #d 'kill stdin thread'
324
- in_thr.kill
325
- # @return_code = exec_thr.value
326
- #d 'command end'
399
+ in_thr.kill if in_thr&.alive?
327
400
  end
328
- #d 'command completed'
401
+
329
402
  @execute_completed_at = Time.now.utc
330
403
  rescue Errno::ENOENT => err
331
404
  #d 'command error ENOENT triggered by missing command in script'
@@ -343,31 +416,104 @@ module MarkdownExec
343
416
  fout "Error ENOENT: #{err.inspect}"
344
417
  end
345
418
 
419
+ def copy_to_clipboard(required_lines)
420
+ text = required_lines.flatten.join($INPUT_RECORD_SEPARATOR)
421
+ Clipboard.copy(text)
422
+ fout "Clipboard updated: #{required_lines.count} blocks," \
423
+ " #{required_lines.flatten.count} lines," \
424
+ " #{text.length} characters"
425
+ end
426
+
346
427
  def count_blocks_in_filename
347
- fenced_start_and_end_match = Regexp.new @options[:fenced_start_and_end_match]
428
+ fenced_start_and_end_regex = Regexp.new @options[:fenced_start_and_end_regex]
348
429
  cnt = 0
349
430
  cfile.readlines(@options[:filename]).each do |line|
350
- cnt += 1 if line.match(fenced_start_and_end_match)
431
+ cnt += 1 if line.match(fenced_start_and_end_regex)
351
432
  end
352
433
  cnt / 2
353
434
  end
354
435
 
436
+ def create_and_write_file_with_permissions(file_path, content, chmod_value)
437
+ dirname = File.dirname(file_path)
438
+ FileUtils.mkdir_p dirname
439
+ File.write(file_path, content)
440
+ return if chmod_value.zero?
441
+
442
+ File.chmod chmod_value, file_path
443
+ end
444
+
445
+ # Deletes a required temporary file specified by an environment variable.
446
+ # The function checks if the file exists before attempting to delete it.
447
+ # Clears the environment variable after deletion.
448
+ #
449
+ def delete_required_temp_file
450
+ temp_blocks_file_path = ENV.fetch('MDE_LINK_REQUIRED_FILE', nil)
451
+
452
+ return if temp_blocks_file_path.nil? || temp_blocks_file_path.empty?
453
+
454
+ FileUtils.rm_f(temp_blocks_file_path)
455
+
456
+ clear_required_file
457
+ end
458
+
459
+ ## Determines the correct filename to use for searching files
460
+ #
461
+ def determine_filename(specified_filename: nil, specified_folder: nil, default_filename: nil,
462
+ default_folder: nil, filetree: nil)
463
+ if specified_filename&.present?
464
+ return specified_filename if specified_filename.start_with?('/')
465
+
466
+ File.join(specified_folder || default_folder, specified_filename)
467
+ elsif specified_folder&.present?
468
+ File.join(specified_folder,
469
+ filetree ? @options[:md_filename_match] : @options[:md_filename_glob])
470
+ else
471
+ File.join(default_folder, default_filename)
472
+ end
473
+ end
474
+
355
475
  # :reek:DuplicateMethodCall
356
- def display_required_code(opts, required_blocks)
476
+ def display_required_code(opts, required_lines)
357
477
  frame = opts[:output_divider].send(opts[:output_divider_color].to_sym)
358
478
  fout frame
359
- required_blocks.each { |cb| fout cb }
479
+ required_lines.each { |cb| fout cb }
360
480
  fout frame
361
481
  end
362
482
 
363
- # :reek:DuplicateMethodCall
364
- def exec_block(options, _block_name = '')
365
- options = calculated_options.merge(options)
366
- update_options options, over: false
483
+ def execute_approved_block(opts, required_lines)
484
+ write_command_file(opts, required_lines)
485
+ command_execute(
486
+ opts,
487
+ required_lines.flatten.join("\n"),
488
+ args: opts.fetch(:pass_args, [])
489
+ )
490
+ initialize_and_save_execution_output
491
+ output_execution_summary
492
+ output_execution_result
493
+ end
367
494
 
368
- # document and block reports
369
- #
370
- files = list_files_per_options(options)
495
+ # Reports and executes block logic
496
+ def execute_block_logic(files)
497
+ @options[:filename] = select_document_if_multiple(files)
498
+ select_approve_and_execute_block({
499
+ bash: true,
500
+ struct: true
501
+ })
502
+ end
503
+
504
+ ## Executes the block specified in the options
505
+ #
506
+ def execute_block_with_error_handling(rest)
507
+ finalize_cli_argument_processing(rest)
508
+ execute_code_block_based_on_options(@options, @options[:block_name])
509
+ rescue FileMissingError => err
510
+ puts "File missing: #{err}"
511
+ end
512
+
513
+ # Main method to execute a block based on options and block_name
514
+ def execute_code_block_based_on_options(options, _block_name = '')
515
+ options = calculated_options.merge(options)
516
+ update_options(options, over: false)
371
517
 
372
518
  simple_commands = {
373
519
  doc_glob: -> { fout options[:md_filename_glob] },
@@ -398,27 +544,59 @@ module MarkdownExec
398
544
  tab_completions: -> { fout tab_completions },
399
545
  menu_export: -> { fout menu_export }
400
546
  }
547
+
548
+ return if execute_simple_commands(simple_commands)
549
+
550
+ files = prepare_file_list(options)
551
+ execute_block_logic(files)
552
+ return unless @options[:output_saved_script_filename]
553
+
554
+ fout "saved_filespec: #{@execute_script_filespec}"
555
+ rescue StandardError => err
556
+ warn(error = "ERROR ** MarkParse.execute_code_block_based_on_options(); #{err.inspect}")
557
+ binding.pry if $tap_enable
558
+ raise ArgumentError, error
559
+ end
560
+
561
+ # Executes command based on the provided option keys
562
+ def execute_simple_commands(simple_commands)
401
563
  simple_commands.each_key do |key|
402
564
  if @options[key]
403
565
  simple_commands[key].call
404
- return # rubocop:disable Lint/NonLocalExitFromIterator
566
+ return true
405
567
  end
406
568
  end
569
+ false
570
+ end
407
571
 
408
- # process
572
+ ##
573
+ # Determines the types of blocks to select based on the filter.
574
+ #
575
+ def filter_block_types
576
+ ## return type of blocks to select
409
577
  #
410
- @options[:filename] = select_md_file(files)
411
- select_approve_and_execute_block({
412
- bash: true,
413
- struct: true
414
- })
415
- return unless @options[:output_saved_script_filename]
578
+ %i[blocks line]
579
+ end
416
580
 
417
- fout "saved_filespec: #{@execute_script_filespec}"
418
- rescue StandardError => err
419
- warn(error = "ERROR ** MarkParse.exec_block(); #{err.inspect}")
420
- binding.pry if $tap_enable
421
- raise ArgumentError, error
581
+ ## post-parse options configuration
582
+ #
583
+ def finalize_cli_argument_processing(rest)
584
+ ## position 0: file or folder (optional)
585
+ #
586
+ if (pos = rest.shift)&.present?
587
+ if Dir.exist?(pos)
588
+ @options[:path] = pos
589
+ elsif File.exist?(pos)
590
+ @options[:filename] = pos
591
+ else
592
+ raise FileMissingError, pos, caller
593
+ end
594
+ end
595
+
596
+ ## position 1: block name (optional)
597
+ #
598
+ block_name = rest.shift
599
+ @options[:block_name] = block_name if block_name.present?
422
600
  end
423
601
 
424
602
  ## summarize blocks
@@ -426,7 +604,6 @@ module MarkdownExec
426
604
  def get_block_summary(call_options, fcb)
427
605
  opts = optsmerge call_options
428
606
  # return fcb.body unless opts[:struct]
429
-
430
607
  return fcb unless opts[:bash]
431
608
 
432
609
  fcb.call = fcb.title.match(Regexp.new(opts[:block_calls_scan]))&.fetch(1, nil)
@@ -435,207 +612,282 @@ module MarkdownExec
435
612
  else
436
613
  fcb.title
437
614
  end
438
- bm = option_match_groups(titlexcall, opts[:block_name_match])
439
- fcb.stdin = option_match_groups(titlexcall, opts[:block_stdin_scan])
440
- fcb.stdout = option_match_groups(titlexcall, opts[:block_stdout_scan])
441
- fcb.title = fcb.name = (bm && bm[1] ? bm[:title] : titlexcall)
615
+ bm = extract_named_captures_from_option(titlexcall, opts[:block_name_match])
616
+ fcb.stdin = extract_named_captures_from_option(titlexcall, opts[:block_stdin_scan])
617
+ fcb.stdout = extract_named_captures_from_option(titlexcall, opts[:block_stdout_scan])
618
+
619
+ shell_color_option = SHELL_COLOR_OPTIONS[fcb[:shell]]
620
+ fcb.title = fcb.oname = bm && bm[1] ? bm[:title] : titlexcall
621
+ fcb.dname = if shell_color_option && opts[shell_color_option].present?
622
+ fcb.oname.send(opts[shell_color_option].to_sym)
623
+ else
624
+ fcb.oname
625
+ end
442
626
  fcb
443
627
  end
444
628
 
445
- # :reek:DuplicateMethodCall
446
- # :reek:LongYieldList
447
- # :reek:NestedIterators
448
- def iter_blocks_in_file(opts = {})
449
- # opts = optsmerge call_options, options_block
629
+ ##
630
+ # Handles errors that occur during the block listing process.
631
+ #
632
+ def handle_error(err)
633
+ warn(error = "ERROR ** MarkParse.list_blocks_in_file(); #{err.inspect}")
634
+ warn(caller[0..4])
635
+ raise StandardError, error
636
+ end
450
637
 
451
- unless opts[:filename]&.present?
452
- fout 'No blocks found.'
453
- return
454
- end
638
+ # Handles the link-back operation.
639
+ #
640
+ # @param opts [Hash] Configuration options hash.
641
+ # @return [Array<Symbol, String>] A tuple containing a LOAD_FILE flag and an empty string.
642
+ def handle_back_link(opts)
643
+ history_state_pop(opts)
644
+ [LOAD_FILE, '']
645
+ end
455
646
 
456
- unless File.exist? opts[:filename]
457
- fout 'Document is missing.'
458
- return
647
+ # Handles the execution and display of remainder blocks from a selected menu item.
648
+ #
649
+ # @param mdoc [Object] Document object containing code blocks.
650
+ # @param opts [Hash] Configuration options hash.
651
+ # @param selected [Hash] Selected item from the menu.
652
+ # @return [Array<Symbol, String>] A tuple containing a LOAD_FILE flag and an empty string.
653
+ # @note The function can prompt the user for approval before executing code if opts[:user_must_approve] is true.
654
+ def handle_remainder_blocks(mdoc, opts, selected)
655
+ required_lines = collect_required_code_lines(mdoc, selected, opts: opts)
656
+ if opts[:output_script] || opts[:user_must_approve]
657
+ display_required_code(opts, required_lines)
459
658
  end
659
+ allow = opts[:user_must_approve] ? prompt_for_user_approval(opts, required_lines) : true
660
+ opts[:ir_approve] = allow
661
+ execute_approved_block(opts, required_lines) if opts[:ir_approve]
460
662
 
461
- fenced_start_and_end_match = Regexp.new opts[:fenced_start_and_end_match]
462
- fenced_start_ex = Regexp.new opts[:fenced_start_ex_match]
463
- fcb = FCB.new
464
- in_block = false
465
- headings = []
663
+ [!LOAD_FILE, '']
664
+ end
466
665
 
467
- ## get type of messages to select
468
- #
469
- selected_messages = yield :filter
666
+ # Handles the link-shell operation.
667
+ #
668
+ # @param opts [Hash] Configuration options hash.
669
+ # @param body [Array<String>] The body content.
670
+ # @param mdoc [Object] Document object containing code blocks.
671
+ # @return [Array<Symbol, String>] A tuple containing a LOAD_FILE flag and a block name.
672
+ def handle_shell_link(opts, body, mdoc)
673
+ data = body.present? ? YAML.load(body.join("\n")) : {}
674
+ data_file = data.fetch('file', nil)
675
+ return [!LOAD_FILE, ''] unless data_file
676
+
677
+ history_state_push(mdoc, data_file, opts)
678
+
679
+ data.fetch('vars', []).each do |var|
680
+ ENV[var[0]] = var[1].to_s
681
+ end
470
682
 
471
- cfile.readlines(opts[:filename]).each.with_index do |line, _line_num|
472
- continue unless line
683
+ [LOAD_FILE, data.fetch('block', '')]
684
+ end
473
685
 
474
- if opts[:menu_blocks_with_headings]
475
- if (lm = line.match(Regexp.new(opts[:heading3_match])))
476
- headings = [headings[0], headings[1], lm[:name]]
477
- elsif (lm = line.match(Regexp.new(opts[:heading2_match])))
478
- headings = [headings[0], lm[:name]]
479
- elsif (lm = line.match(Regexp.new(opts[:heading1_match])))
480
- headings = [lm[:name]]
481
- end
482
- end
686
+ # Handles options for the shell.
687
+ #
688
+ # @param opts [Hash] Configuration options hash.
689
+ # @param selected [Hash] Selected item from the menu.
690
+ # @return [Array<Symbol, String>] A tuple containing a NOT_LOAD_FILE flag and an empty string.
691
+ def handle_shell_opts(opts, selected)
692
+ data = YAML.load(selected[:body].join("\n"))
693
+ data.each_key do |key|
694
+ opts[key.to_sym] = value = data[key].to_s
695
+ next unless opts[:menu_opts_set_format].present?
696
+
697
+ print format(
698
+ opts[:menu_opts_set_format],
699
+ { key: key,
700
+ value: value }
701
+ ).send(opts[:menu_opts_set_color].to_sym)
702
+ end
703
+ [!LOAD_FILE, '']
704
+ end
483
705
 
484
- if line.match(fenced_start_and_end_match)
485
- if in_block
486
- # end fcb
487
- #
488
- fcb.name = fcb.title || ''
489
- if fcb.body
490
- if fcb.title.nil? || fcb.title.empty?
491
- fcb.title = fcb.body.join(' ').gsub(/ +/, ' ')[0..64]
492
- end
493
-
494
- if block_given? &&
495
- selected_messages.include?(:blocks) &&
496
- Filter.fcb_select?(opts, fcb)
497
- yield :blocks, fcb
498
- end
499
- end
500
- in_block = false
501
- else
502
- # start fcb
503
- #
504
- in_block = true
505
-
506
- fcb_title_groups = line.match(fenced_start_ex).named_captures.sym_keys
507
- fcb = FCB.new
508
- fcb.headings = headings
509
- fcb.name = fcb_title_groups.fetch(:name, '')
510
- fcb.shell = fcb_title_groups.fetch(:shell, '')
511
- fcb.title = fcb_title_groups.fetch(:name, '')
512
-
513
- # selected fcb
514
- #
515
- fcb.body = []
516
-
517
- rest = fcb_title_groups.fetch(:rest, '')
518
- fcb.reqs, fcb.wraps =
519
- split_array(rest.scan(/\+[^\s]+/).map { |req| req[1..-1] }) do |name|
520
- !name.match(Regexp.new(opts[:block_name_wrapper_match]))
521
- end
522
- fcb.call = rest.match(Regexp.new(opts[:block_calls_scan]))&.to_a&.first
523
- fcb.stdin = if (tn = rest.match(/<(?<type>\$)?(?<name>[A-Za-z_-]\S+)/))
524
- tn.named_captures.sym_keys
525
- end
526
- fcb.stdout = if (tn = rest.match(/>(?<type>\$)?(?<name>[A-Za-z_\-.\w]+)/))
527
- tn.named_captures.sym_keys
528
- end
529
- end
530
- elsif in_block && fcb.body
531
- dp 'append line to fcb body'
532
- fcb.body += [line.chomp]
533
- elsif block_given? && selected_messages.include?(:line)
534
- dp 'text outside of fcb'
535
- fcb = FCB.new
536
- fcb.body = [line]
537
- yield :line, fcb
706
+ # Handles reading and processing lines from a given IO stream
707
+ #
708
+ # @param stream [IO] The IO stream to read from (e.g., stdout, stderr, stdin).
709
+ # @param file_type [Symbol] The type of file to which the stream corresponds.
710
+ def handle_stream(opts, stream, file_type, swap: false)
711
+ Thread.new do
712
+ until (line = stream.gets).nil?
713
+ @execute_files[file_type] = @execute_files[file_type] + [line.strip]
714
+ print line if opts[:output_stdout]
715
+ yield line if block_given?
538
716
  end
717
+ rescue IOError
718
+ #d 'stdout IOError, thread killed, do nothing'
539
719
  end
540
720
  end
541
721
 
542
- def split_array(arr)
543
- true_list = []
544
- false_list = []
722
+ def history_state_exist?
723
+ history = ENV.fetch(MDE_HISTORY_ENV_NAME, '')
724
+ history.present? ? history : nil
725
+ end
545
726
 
546
- arr.each do |element|
547
- if yield(element)
548
- true_list << element
549
- else
550
- false_list << element
727
+ def history_state_partition(opts)
728
+ unit, rest = StringUtil.partition_at_first(
729
+ ENV.fetch(MDE_HISTORY_ENV_NAME, ''),
730
+ opts[:history_document_separator]
731
+ )
732
+ { unit: unit, rest: rest }.tap_inspect
733
+ end
734
+
735
+ def history_state_pop(opts)
736
+ state = history_state_partition(opts)
737
+ opts[:filename] = state[:unit]
738
+ ENV[MDE_HISTORY_ENV_NAME] = state[:rest]
739
+ delete_required_temp_file
740
+ end
741
+
742
+ def history_state_push(mdoc, data_file, opts)
743
+ [data_file, opts[:block_name]].tap_inspect 'filename, blockname'
744
+ new_history = opts[:filename] +
745
+ opts[:history_document_separator] +
746
+ ENV.fetch(MDE_HISTORY_ENV_NAME, '')
747
+ opts[:filename] = data_file
748
+ write_required_blocks_to_temp_file(mdoc, opts[:block_name], opts)
749
+ ENV[MDE_HISTORY_ENV_NAME] = new_history
750
+ end
751
+
752
+ ## Sets up the options and returns the parsed arguments
753
+ #
754
+ def initialize_and_parse_cli_options
755
+ @options = base_options
756
+ read_configuration_file!(@options, ".#{MarkdownExec::APP_NAME.downcase}.yml")
757
+
758
+ @option_parser = OptionParser.new do |opts|
759
+ executable_name = File.basename($PROGRAM_NAME)
760
+ opts.banner = [
761
+ "#{MarkdownExec::APP_NAME}" \
762
+ " - #{MarkdownExec::APP_DESC} (#{MarkdownExec::VERSION})",
763
+ "Usage: #{executable_name} [(path | filename [block_name])] [options]"
764
+ ].join("\n")
765
+
766
+ menu_iter do |item|
767
+ menu_option_append opts, @options, item
551
768
  end
552
769
  end
770
+ @option_parser.load
771
+ @option_parser.environment
772
+
773
+ rest = @option_parser.parse!(arguments_for_mde)
774
+ @options[:pass_args] = ARGV[rest.count + 1..]
775
+
776
+ rest
777
+ end
778
+
779
+ def initialize_and_save_execution_output
780
+ return unless @options[:save_execution_output]
781
+
782
+ @options[:logged_stdout_filename] =
783
+ SavedAsset.stdout_name(blockname: @options[:block_name],
784
+ filename: File.basename(@options[:filename], '.*'),
785
+ prefix: @options[:logged_stdout_filename_prefix],
786
+ time: Time.now.utc)
787
+
788
+ @logged_stdout_filespec =
789
+ @options[:logged_stdout_filespec] =
790
+ File.join @options[:saved_stdout_folder],
791
+ @options[:logged_stdout_filename]
792
+ @logged_stdout_filespec = @options[:logged_stdout_filespec]
793
+ write_execution_output_to_file
794
+ end
553
795
 
554
- [true_list, false_list]
796
+ # Initializes variables for regex and other states
797
+ def initialize_state(opts)
798
+ {
799
+ fenced_start_and_end_regex: Regexp.new(opts[:fenced_start_and_end_regex]),
800
+ fenced_start_extended_regex: Regexp.new(opts[:fenced_start_extended_regex]),
801
+ fcb: FCB.new,
802
+ in_fenced_block: false,
803
+ headings: []
804
+ }
555
805
  end
556
806
 
557
- # # Example usage:
558
- # array = [1, 2, 3, 4, 5]
559
- # result = split_array(array) { |num| num.even? }
560
- # puts "True List: #{result[0]}" # Output: True List: [2, 4]
561
- # puts "False List: #{result[1]}" # Output: False List: [1, 3, 5]
807
+ # Main function to iterate through blocks in file
808
+ def iter_blocks_in_file(opts = {}, &block)
809
+ return unless check_file_existence(opts[:filename])
810
+
811
+ state = initialize_state(opts)
812
+
813
+ # get type of messages to select
814
+ selected_messages = yield :filter
815
+
816
+ cfile.readlines(opts[:filename]).each do |line|
817
+ next unless line
562
818
 
563
- # return body, title if option.struct
564
- # return body if not struct
819
+ update_line_and_block_state(line, state, opts, selected_messages, &block)
820
+ end
821
+ end
822
+
823
+ ##
824
+ # Returns a list of blocks in a given file, including dividers, tasks, and other types of blocks.
825
+ # The list can be customized via call_options and options_block.
826
+ #
827
+ # @param call_options [Hash] Options passed as an argument.
828
+ # @param options_block [Proc] Block for dynamic option manipulation.
829
+ # @return [Array<FCB>] An array of FCB objects representing the blocks.
565
830
  #
566
831
  def list_blocks_in_file(call_options = {}, &options_block)
567
832
  opts = optsmerge(call_options, options_block)
568
833
  use_chrome = !opts[:no_chrome]
569
834
 
570
835
  blocks = []
571
- if opts[:menu_initial_divider].present? && use_chrome
572
- blocks.push FCB.new({
573
- # name: '',
574
- chrome: true,
575
- name: format(
576
- opts[:menu_divider_format],
577
- opts[:menu_initial_divider]
578
- ).send(opts[:menu_divider_color].to_sym),
579
- disabled: '' # __LINE__.to_s
580
- })
581
- end
836
+ append_initial_divider(blocks, opts) if use_chrome
582
837
 
583
838
  iter_blocks_in_file(opts) do |btype, fcb|
584
839
  case btype
585
840
  when :filter
586
- ## return type of blocks to select
587
- #
588
- %i[blocks line]
589
-
841
+ filter_block_types
590
842
  when :line
591
- ## convert line to block
592
- #
593
- if opts[:menu_divider_match].present? &&
594
- (mbody = fcb.body[0].match opts[:menu_divider_match])
595
- if use_chrome
596
- blocks.push FCB.new(
597
- { chrome: true,
598
- disabled: '',
599
- name: format(opts[:menu_divider_format],
600
- mbody[:name]).send(opts[:menu_divider_color].to_sym) }
601
- )
602
- end
603
- elsif opts[:menu_task_match].present? &&
604
- (fcb.body[0].match opts[:menu_task_match])
605
- if use_chrome
606
- blocks.push FCB.new(
607
- { chrome: true,
608
- disabled: '',
609
- name: format(
610
- opts[:menu_task_format],
611
- $~.named_captures.transform_keys(&:to_sym)
612
- ).send(opts[:menu_task_color].to_sym) }
613
- )
614
- end
615
- else
616
- # line not added
617
- end
843
+ process_line_blocks(blocks, fcb, opts, use_chrome)
618
844
  when :blocks
619
- ## enhance fcb with block summary
620
- #
621
- blocks.push get_block_summary(opts, fcb) ### if Filter.fcb_select? opts, fcb
845
+ append_block_summary(blocks, fcb, opts)
622
846
  end
623
847
  end
624
848
 
625
- if opts[:menu_divider_format].present? && opts[:menu_final_divider].present? && use_chrome && use_chrome
626
- blocks.push FCB.new(
627
- { chrome: true,
628
- disabled: '',
629
- name: format(opts[:menu_divider_format],
630
- opts[:menu_final_divider])
631
- .send(opts[:menu_divider_color].to_sym) }
632
- )
633
- end
849
+ append_final_divider(blocks, opts) if use_chrome
634
850
  blocks
635
851
  rescue StandardError => err
636
- warn(error = "ERROR ** MarkParse.list_blocks_in_file(); #{err.inspect}")
637
- warn(caller[0..4])
638
- raise StandardError, error
852
+ handle_error(err)
853
+ end
854
+
855
+ ##
856
+ # Processes lines within the file and converts them into blocks if they match certain criteria.
857
+ #
858
+ def process_line_blocks(blocks, fcb, opts, use_chrome)
859
+ ## convert line to block
860
+ #
861
+ if opts[:menu_divider_match].present? &&
862
+ (mbody = fcb.body[0].match opts[:menu_divider_match])
863
+ if use_chrome
864
+ blocks.push FCB.new(
865
+ { chrome: true,
866
+ disabled: '',
867
+ dname: format(opts[:menu_divider_format],
868
+ mbody[:name]).send(opts[:menu_divider_color].to_sym),
869
+ oname: mbody[:name] }
870
+ )
871
+ end
872
+ elsif opts[:menu_task_match].present? &&
873
+ (fcb.body[0].match opts[:menu_task_match])
874
+ if use_chrome
875
+ blocks.push FCB.new(
876
+ { chrome: true,
877
+ disabled: '',
878
+ dname: format(
879
+ opts[:menu_task_format],
880
+ $~.named_captures.transform_keys(&:to_sym)
881
+ ).send(opts[:menu_task_color].to_sym),
882
+ oname: format(
883
+ opts[:menu_task_format],
884
+ $~.named_captures.transform_keys(&:to_sym)
885
+ ) }
886
+ )
887
+ end
888
+ else
889
+ # line not added
890
+ end
639
891
  end
640
892
 
641
893
  def list_default_env
@@ -662,39 +914,22 @@ module MarkdownExec
662
914
 
663
915
  def list_files_per_options(options)
664
916
  list_files_specified(
665
- specified_filename: options[:filename]&.present? ? options[:filename] : nil,
666
- specified_folder: options[:path],
667
- default_filename: 'README.md',
668
- default_folder: '.'
917
+ determine_filename(
918
+ specified_filename: options[:filename]&.present? ? options[:filename] : nil,
919
+ specified_folder: options[:path],
920
+ default_filename: 'README.md',
921
+ default_folder: '.'
922
+ )
669
923
  )
670
924
  end
671
925
 
672
- # :reek:LongParameterList
673
- def list_files_specified(specified_filename: nil, specified_folder: nil,
674
- default_filename: nil, default_folder: nil, filetree: nil)
675
- fn = File.join(if specified_filename&.present?
676
- if specified_filename.start_with? '/'
677
- [specified_filename]
678
- elsif specified_folder&.present?
679
- [specified_folder, specified_filename]
680
- else
681
- [default_folder, specified_filename]
682
- end
683
- elsif specified_folder&.present?
684
- if filetree
685
- [specified_folder, @options[:md_filename_match]]
686
- else
687
- [specified_folder, @options[:md_filename_glob]]
688
- end
689
- else
690
- [default_folder, default_filename]
691
- end)
692
- if filetree
693
- filetree.select do |filename|
694
- filename == fn || filename.match(/^#{fn}$/) || filename.match(%r{^#{fn}/.+$})
695
- end
696
- else
697
- Dir.glob(fn)
926
+ ## Searches for files based on the specified or default filenames and folders
927
+ #
928
+ def list_files_specified(fn, filetree = nil)
929
+ return Dir.glob(fn) unless filetree
930
+
931
+ filetree.select do |filename|
932
+ filename == fn || filename.match(/^#{fn}$/) || filename.match(%r{^#{fn}/.+$})
698
933
  end
699
934
  end
700
935
 
@@ -703,17 +938,6 @@ module MarkdownExec
703
938
  @options[:md_filename_glob]))
704
939
  end
705
940
 
706
- def blocks_per_opts(blocks, opts)
707
- if opts[:struct]
708
- blocks
709
- else
710
- # blocks.map(&:name)
711
- blocks.map do |block|
712
- block.fetch(:text, nil) || block.fetch(:name, nil)
713
- end
714
- end.compact.reject(&:empty?)
715
- end
716
-
717
941
  ## output type (body string or full object) per option struct and bash
718
942
  #
719
943
  def list_named_blocks_in_file(call_options = {}, &options_block)
@@ -726,6 +950,17 @@ module MarkdownExec
726
950
  blocks_per_opts(blocks, opts)
727
951
  end
728
952
 
953
+ ## Handles the file loading and returns the blocks in the file and MDoc instance
954
+ #
955
+ def load_file_and_prepare_menu(opts)
956
+ blocks_in_file = list_blocks_in_file(opts.merge(struct: true))
957
+ mdoc = MDoc.new(blocks_in_file) do |nopts|
958
+ opts.merge!(nopts)
959
+ end
960
+ blocks_menu = mdoc.fcbs_per_options(opts.merge(struct: true))
961
+ [blocks_in_file, blocks_menu, mdoc]
962
+ end
963
+
729
964
  def make_block_labels(call_options = {})
730
965
  opts = options.merge(call_options)
731
966
  list_blocks_in_file(opts).map do |fcb|
@@ -741,6 +976,32 @@ module MarkdownExec
741
976
  end.compact
742
977
  end
743
978
 
979
+ def menu_export(data = menu_for_optparse)
980
+ data.map do |item|
981
+ item.delete(:procname)
982
+ item
983
+ end.to_yaml
984
+ end
985
+
986
+ def menu_for_blocks(menu_options)
987
+ options = calculated_options.merge menu_options
988
+ menu = []
989
+ iter_blocks_in_file(options) do |btype, fcb|
990
+ case btype
991
+ when :filter
992
+ %i[blocks line]
993
+ when :line
994
+ if options[:menu_divider_match] &&
995
+ (mbody = fcb.body[0].match(options[:menu_divider_match]))
996
+ menu.push FCB.new({ dname: mbody[:name], oname: mbody[:name], disabled: '' })
997
+ end
998
+ when :blocks
999
+ menu += [fcb.oname]
1000
+ end
1001
+ end
1002
+ menu
1003
+ end
1004
+
744
1005
  # :reek:DuplicateMethodCall
745
1006
  # :reek:NestedIterators
746
1007
  def menu_for_optparse
@@ -764,11 +1025,11 @@ module MarkdownExec
764
1025
  }
765
1026
  when 'path'
766
1027
  lambda { |value|
767
- read_configuration_file! options, value
1028
+ read_configuration_file!(options, value)
768
1029
  }
769
1030
  when 'show_config'
770
1031
  lambda { |_|
771
- options_finalize options
1032
+ finalize_cli_argument_processing(options)
772
1033
  fout options.sort_by_key.to_yaml
773
1034
  }
774
1035
  when 'val_as_bool'
@@ -792,33 +1053,14 @@ module MarkdownExec
792
1053
  end
793
1054
  end
794
1055
 
795
- def menu_for_blocks(menu_options)
796
- options = calculated_options.merge menu_options
797
- menu = []
798
- iter_blocks_in_file(options) do |btype, fcb|
799
- case btype
800
- when :filter
801
- %i[blocks line]
802
- when :line
803
- if options[:menu_divider_match] &&
804
- (mbody = fcb.body[0].match(options[:menu_divider_match]))
805
- menu.push FCB.new({ name: mbody[:name], disabled: '' })
806
- end
807
- when :blocks
808
- menu += [fcb.name]
809
- end
810
- end
811
- menu
1056
+ def menu_help
1057
+ @option_parser.help
812
1058
  end
813
1059
 
814
1060
  def menu_iter(data = menu_for_optparse, &block)
815
1061
  data.map(&block)
816
1062
  end
817
1063
 
818
- def menu_help
819
- @option_parser.help
820
- end
821
-
822
1064
  def menu_option_append(opts, options, item)
823
1065
  return unless item[:long_name].present? || item[:short_name].present?
824
1066
 
@@ -846,27 +1088,6 @@ module MarkdownExec
846
1088
  ].compact)
847
1089
  end
848
1090
 
849
- ## post-parse options configuration
850
- #
851
- def options_finalize(rest)
852
- ## position 0: file or folder (optional)
853
- #
854
- if (pos = rest.fetch(0, nil))&.present?
855
- if Dir.exist?(pos)
856
- @options[:path] = pos
857
- elsif File.exist?(pos)
858
- @options[:filename] = pos
859
- else
860
- raise FileMissingError, pos, caller
861
- end
862
- end
863
-
864
- ## position 1: block name (optional)
865
- #
866
- block_name = rest.fetch(1, nil)
867
- @options[:block_name] = block_name if block_name.present?
868
- end
869
-
870
1091
  # :reek:ControlParameter
871
1092
  def optsmerge(call_options = {}, options_block = nil)
872
1093
  class_call_options = @options.merge(call_options || {})
@@ -910,25 +1131,113 @@ module MarkdownExec
910
1131
  }
911
1132
  end
912
1133
 
913
- ## insert exit option at head or tail
1134
+ # Prepare the blocks menu by adding labels and other necessary details.
914
1135
  #
915
- def prompt_menu_add_exit(_prompt_text, items, exit_option, _opts = {})
916
- if @options[:menu_exit_at_top]
917
- (@options[:menu_with_exit] ? [exit_option] : []) + items
918
- else
919
- items + (@options[:menu_with_exit] ? [exit_option] : [])
1136
+ # @param blocks_in_file [Array<Hash>] The list of blocks from the file.
1137
+ # @param opts [Hash] The options hash.
1138
+ # @return [Array<Hash>] The updated blocks menu.
1139
+ def prepare_blocks_menu(blocks_in_file, opts)
1140
+ # next if fcb.fetch(:disabled, false)
1141
+ # next unless fcb.fetch(:name, '').present?
1142
+ blocks_in_file.map do |fcb|
1143
+ fcb.merge!(
1144
+ name: fcb.dname,
1145
+ label: BlockLabel.make(
1146
+ body: fcb[:body],
1147
+ filename: opts[:filename],
1148
+ headings: fcb.fetch(:headings, []),
1149
+ menu_blocks_with_docname: opts[:menu_blocks_with_docname],
1150
+ menu_blocks_with_headings: opts[:menu_blocks_with_headings],
1151
+ text: fcb[:text],
1152
+ title: fcb[:title]
1153
+ )
1154
+ )
1155
+ fcb.to_h
1156
+ end.compact
1157
+ end
1158
+
1159
+ # Prepares and fetches file listings
1160
+ def prepare_file_list(options)
1161
+ list_files_per_options(options)
1162
+ end
1163
+
1164
+ def process_fenced_block(fcb, opts, selected_messages, &block)
1165
+ fcb.oname = fcb.dname = fcb.title || ''
1166
+ return unless fcb.body
1167
+
1168
+ set_fcb_title(fcb)
1169
+
1170
+ if block &&
1171
+ selected_messages.include?(:blocks) &&
1172
+ Filter.fcb_select?(opts, fcb)
1173
+ block.call :blocks, fcb
1174
+ end
1175
+ end
1176
+
1177
+ def process_line(line, _opts, selected_messages, &block)
1178
+ return unless block && selected_messages.include?(:line)
1179
+
1180
+ # dp 'text outside of fcb'
1181
+ fcb = FCB.new
1182
+ fcb.body = [line]
1183
+ block.call(:line, fcb)
1184
+ end
1185
+
1186
+ ##
1187
+ # Presents a menu to the user for approving an action and performs additional tasks based on the selection.
1188
+ # The function provides options for approval, rejection, copying data to clipboard, or saving data to a file.
1189
+ #
1190
+ # @param opts [Hash] A hash containing various options for the menu.
1191
+ # @param required_lines [Array<String>] Lines of text or code that are subject to user approval.
1192
+ #
1193
+ # @option opts [String] :prompt_approve_block Prompt text for the approval menu.
1194
+ # @option opts [String] :prompt_yes Text for the 'Yes' choice in the menu.
1195
+ # @option opts [String] :prompt_no Text for the 'No' choice in the menu.
1196
+ # @option opts [String] :prompt_script_to_clipboard Text for the 'Copy to Clipboard' choice in the menu.
1197
+ # @option opts [String] :prompt_save_script Text for the 'Save to File' choice in the menu.
1198
+ #
1199
+ # @return [Boolean] Returns true if the user approves (selects 'Yes'), false otherwise.
1200
+ ##
1201
+ def prompt_for_user_approval(opts, required_lines)
1202
+ # Present a selection menu for user approval.
1203
+ sel = @prompt.select(opts[:prompt_approve_block], filter: true) do |menu|
1204
+ menu.default 1
1205
+ menu.choice opts[:prompt_yes], 1
1206
+ menu.choice opts[:prompt_no], 2
1207
+ menu.choice opts[:prompt_script_to_clipboard], 3
1208
+ menu.choice opts[:prompt_save_script], 4
920
1209
  end
1210
+
1211
+ if sel == 3
1212
+ copy_to_clipboard(required_lines)
1213
+ elsif sel == 4
1214
+ save_to_file(opts, required_lines)
1215
+ end
1216
+
1217
+ sel == 1
1218
+ end
1219
+
1220
+ ## insert back option at head or tail
1221
+ #
1222
+ ## Adds a back option at the head or tail of a menu
1223
+ #
1224
+ def prompt_menu_add_back(items, label)
1225
+ return items unless @options[:menu_with_back] && history_state_exist?
1226
+
1227
+ state = history_state_partition(@options)
1228
+ @hs_curr = state[:unit]
1229
+ @hs_rest = state[:rest]
1230
+ @options[:menu_back_at_top] ? [label] + items : items + [label]
921
1231
  end
922
1232
 
923
- ## tty prompt to select
924
- # insert exit option at head or tail
925
- # return selected option or nil
1233
+ ## insert exit option at head or tail
926
1234
  #
927
- def prompt_with_quit(prompt_text, items, opts = {})
928
- exit_option = '* Exit'
929
- sel = @prompt.select(prompt_text, prompt_menu_add_exit(prompt_text, items, exit_option, opts),
930
- opts.merge(filter: true))
931
- sel == exit_option ? nil : sel
1235
+ def prompt_menu_add_exit(items, label)
1236
+ if @options[:menu_exit_at_top]
1237
+ (@options[:menu_with_exit] ? [label] : []) + items
1238
+ else
1239
+ items + (@options[:menu_with_exit] ? [label] : [])
1240
+ end
932
1241
  end
933
1242
 
934
1243
  # :reek:UtilityFunction ### temp
@@ -939,57 +1248,33 @@ module MarkdownExec
939
1248
  .transform_keys(&:to_sym))
940
1249
  end
941
1250
 
942
- # :reek:NestedIterators
943
- def run
944
- ## default configuration
945
- #
946
- @options = base_options
947
-
948
- ## read local configuration file
949
- #
950
- read_configuration_file! @options,
951
- ".#{MarkdownExec::APP_NAME.downcase}.yml"
1251
+ # Reads required code blocks from a temporary file specified by an environment variable.
1252
+ #
1253
+ # @return [Array<String>] An array containing the lines read from the temporary file.
1254
+ # @note Relies on the 'MDE_LINK_REQUIRED_FILE' environment variable to locate the file.
1255
+ def read_required_blocks_from_temp_file
1256
+ temp_blocks = []
952
1257
 
953
- @option_parser = option_parser = OptionParser.new do |opts|
954
- executable_name = File.basename($PROGRAM_NAME)
955
- opts.banner = [
956
- "#{MarkdownExec::APP_NAME}" \
957
- " - #{MarkdownExec::APP_DESC} (#{MarkdownExec::VERSION})",
958
- "Usage: #{executable_name} [(path | filename [block_name])] [options]"
959
- ].join("\n")
1258
+ temp_blocks_file_path = ENV.fetch('MDE_LINK_REQUIRED_FILE', nil)
1259
+ return temp_blocks if temp_blocks_file_path.nil? || temp_blocks_file_path.empty?
960
1260
 
961
- menu_iter do |item|
962
- menu_option_append opts, options, item
963
- end
964
- end
965
- option_parser.load # filename defaults to basename of the program
966
- # without suffix in a directory ~/.options
967
- option_parser.environment # env defaults to the basename of the program
968
- # child_argv = arguments_for_child
969
- rest = option_parser.parse!(arguments_for_mde) # (into: options)
970
-
971
- begin
972
- options_finalize rest
973
- exec_block options, options[:block_name]
974
- rescue FileMissingError => err
975
- puts "File missing: #{err}"
1261
+ if File.exist?(temp_blocks_file_path)
1262
+ temp_blocks = File.readlines(temp_blocks_file_path, chomp: true)
976
1263
  end
1264
+
1265
+ temp_blocks
1266
+ end
1267
+
1268
+ def run
1269
+ clear_required_file
1270
+ execute_block_with_error_handling(initialize_and_parse_cli_options)
1271
+ delete_required_temp_file
977
1272
  rescue StandardError => err
978
1273
  warn(error = "ERROR ** MarkParse.run(); #{err.inspect}")
979
1274
  binding.pry if $tap_enable
980
1275
  raise ArgumentError, error
981
1276
  end
982
1277
 
983
- def saved_name_split(name)
984
- # rubocop:disable Layout/LineLength
985
- mf = /#{@options[:saved_script_filename_prefix]}_(?<time>[0-9\-]+)_(?<file>.+)_,_(?<block>.+)\.sh/.match name
986
- # rubocop:enable Layout/LineLength
987
- return unless mf
988
-
989
- @options[:block_name] = mf[:block]
990
- @options[:filename] = mf[:file].gsub(FNR12, FNR11)
991
- end
992
-
993
1278
  def run_last_script
994
1279
  filename = SavedFilesMatcher.most_recent(@options[:saved_script_folder],
995
1280
  @options[:saved_script_glob])
@@ -1000,30 +1285,20 @@ module MarkdownExec
1000
1285
  select_approve_and_execute_block({})
1001
1286
  end
1002
1287
 
1003
- def save_execution_output
1004
- return unless @options[:save_execution_output]
1005
-
1006
- @options[:logged_stdout_filename] =
1007
- SavedAsset.stdout_name(blockname: @options[:block_name],
1008
- filename: File.basename(@options[:filename], '.*'),
1009
- prefix: @options[:logged_stdout_filename_prefix],
1010
- time: Time.now.utc)
1288
+ def save_to_file(opts, required_lines)
1289
+ write_command_file(opts.merge(save_executed_script: true),
1290
+ required_lines)
1291
+ fout "File saved: #{@options[:saved_filespec]}"
1292
+ end
1011
1293
 
1012
- @options[:logged_stdout_filespec] =
1013
- File.join @options[:saved_stdout_folder],
1014
- @options[:logged_stdout_filename]
1015
- @logged_stdout_filespec = @options[:logged_stdout_filespec]
1016
- (dirname = File.dirname(@options[:logged_stdout_filespec]))
1017
- FileUtils.mkdir_p dirname
1294
+ def saved_name_split(name)
1295
+ # rubocop:disable Layout/LineLength
1296
+ mf = /#{@options[:saved_script_filename_prefix]}_(?<time>[0-9\-]+)_(?<file>.+)_,_(?<block>.+)\.sh/.match name
1297
+ # rubocop:enable Layout/LineLength
1298
+ return unless mf
1018
1299
 
1019
- ol = ["-STDOUT-\n"]
1020
- ol += @execute_files&.fetch(EF_STDOUT, [])
1021
- ol += ["\n-STDERR-\n"]
1022
- ol += @execute_files&.fetch(EF_STDERR, [])
1023
- ol += ["\n-STDIN-\n"]
1024
- ol += @execute_files&.fetch(EF_STDIN, [])
1025
- ol += ["\n"]
1026
- File.write(@options[:logged_stdout_filespec], ol.join)
1300
+ @options[:block_name] = mf[:block]
1301
+ @options[:filename] = mf[:file].gsub(FNR12, FNR11)
1027
1302
  end
1028
1303
 
1029
1304
  # Select and execute a code block from a Markdown document.
@@ -1036,51 +1311,37 @@ module MarkdownExec
1036
1311
  # @return [Nil] Returns nil if no code block is selected or an error occurs.
1037
1312
  def select_approve_and_execute_block(call_options, &options_block)
1038
1313
  opts = optsmerge(call_options, options_block)
1039
- blocks_in_file = list_blocks_in_file(opts.merge(struct: true))
1040
- mdoc = MDoc.new(blocks_in_file) do |nopts|
1041
- opts.merge!(nopts)
1042
- end
1043
- blocks_menu = mdoc.fcbs_per_options(opts.merge(struct: true))
1044
-
1045
1314
  repeat_menu = true && !opts[:block_name].present?
1315
+ load_file = !LOAD_FILE
1316
+ default = 1
1317
+
1046
1318
  loop do
1047
- unless opts[:block_name].present?
1048
- pt = opts[:prompt_select_block].to_s
1049
-
1050
- bm = blocks_menu.map do |fcb|
1051
- # next if fcb.fetch(:disabled, false)
1052
- # next unless fcb.fetch(:name, '').present?
1053
-
1054
- fcb.merge!(
1055
- label: BlockLabel.make(
1056
- body: fcb[:body],
1057
- filename: opts[:filename],
1058
- headings: fcb.fetch(:headings, []),
1059
- menu_blocks_with_docname: opts[:menu_blocks_with_docname],
1060
- menu_blocks_with_headings: opts[:menu_blocks_with_headings],
1061
- text: fcb[:text],
1062
- title: fcb[:title]
1063
- )
1064
- )
1065
-
1066
- fcb.to_h
1067
- end.compact
1068
- return nil if bm.count.zero?
1069
-
1070
- sel = prompt_with_quit(pt, bm, per_page: opts[:select_page_height])
1071
- return nil if sel.nil?
1072
-
1073
- ## store selected option
1074
- #
1075
- label_block = blocks_in_file.select do |fcb|
1076
- fcb[:label] == sel
1077
- end.fetch(0, nil)
1078
- opts[:block_name] = @options[:block_name] = label_block.fetch(:name, '')
1079
- end
1080
- approve_and_execute_block(opts, mdoc)
1081
- break unless repeat_menu
1319
+ loop do
1320
+ opts[:back] = false
1321
+ blocks_in_file, blocks_menu, mdoc = load_file_and_prepare_menu(opts)
1322
+
1323
+ unless opts[:block_name].present?
1324
+ block_name, state = wait_for_user_selection(blocks_in_file, blocks_menu, default,
1325
+ opts)
1326
+ case state
1327
+ when :exit
1328
+ return nil
1329
+ when :back
1330
+ opts[:block_name] = block_name[:option]
1331
+ opts[:back] = true
1332
+ when :continue
1333
+ opts[:block_name] = block_name
1334
+ end
1335
+ end
1336
+
1337
+ load_file, next_block_name = approve_and_execute_block(opts, mdoc)
1338
+ default = load_file == LOAD_FILE ? 1 : opts[:block_name]
1339
+ opts[:block_name] = next_block_name
1082
1340
 
1083
- opts[:block_name] = ''
1341
+ break if state == :continue && load_file == LOAD_FILE
1342
+ break unless repeat_menu
1343
+ end
1344
+ break if load_file != LOAD_FILE
1084
1345
  end
1085
1346
  rescue StandardError => err
1086
1347
  warn(error = "ERROR ** MarkParse.select_approve_and_execute_block(); #{err.inspect}")
@@ -1089,25 +1350,53 @@ module MarkdownExec
1089
1350
  raise ArgumentError, error
1090
1351
  end
1091
1352
 
1092
- def select_md_file(files = list_markdown_files_in_path)
1093
- opts = options
1094
- if (count = files.count) == 1
1095
- files[0]
1096
- elsif count >= 2
1097
- prompt_with_quit opts[:prompt_select_md].to_s, files,
1098
- per_page: opts[:select_page_height]
1353
+ def select_document_if_multiple(files = list_markdown_files_in_path)
1354
+ return files[0] if (count = files.count) == 1
1355
+
1356
+ return unless count >= 2
1357
+
1358
+ opts = options.dup
1359
+ select_option_or_exit opts[:prompt_select_md].to_s, files,
1360
+ opts.merge(per_page: opts[:select_page_height])
1361
+ end
1362
+
1363
+ # Presents a TTY prompt to select an option or exit, returns selected option or nil
1364
+ def select_option_or_exit(prompt_text, items, opts = {})
1365
+ result = select_option_with_metadata(prompt_text, items, opts)
1366
+ return unless result.fetch(:option, nil)
1367
+
1368
+ result[:selected]
1369
+ end
1370
+
1371
+ # Presents a TTY prompt to select an option or exit, returns metadata including option and selected
1372
+ def select_option_with_metadata(prompt_text, items, opts = {})
1373
+ selection = @prompt.select(prompt_text,
1374
+ prompt_menu_add_exit(
1375
+ prompt_menu_add_back(
1376
+ items,
1377
+ opts[:menu_option_back_name]
1378
+ ),
1379
+ opts[:menu_option_exit_name]
1380
+ ),
1381
+ opts.merge(filter: true))
1382
+ if selection == opts[:menu_option_back_name]
1383
+ { option: selection, curr: @hs_curr, rest: @hs_rest, shell: BLOCK_TYPE_LINK }
1384
+ elsif selection == opts[:menu_option_exit_name]
1385
+ { option: selection }
1386
+ else
1387
+ { selected: selection }
1099
1388
  end
1100
1389
  end
1101
1390
 
1102
1391
  def select_recent_output
1103
- filename = prompt_with_quit(
1392
+ filename = select_option_or_exit(
1104
1393
  @options[:prompt_select_output].to_s,
1105
1394
  list_recent_output(
1106
1395
  @options[:saved_stdout_folder],
1107
1396
  @options[:saved_stdout_glob],
1108
1397
  @options[:list_count]
1109
1398
  ),
1110
- { per_page: @options[:select_page_height] }
1399
+ @options.merge({ per_page: @options[:select_page_height] })
1111
1400
  )
1112
1401
  return unless filename.present?
1113
1402
 
@@ -1115,14 +1404,14 @@ module MarkdownExec
1115
1404
  end
1116
1405
 
1117
1406
  def select_recent_script
1118
- filename = prompt_with_quit(
1407
+ filename = select_option_or_exit(
1119
1408
  @options[:prompt_select_md].to_s,
1120
1409
  list_recent_scripts(
1121
1410
  @options[:saved_script_folder],
1122
1411
  @options[:saved_script_glob],
1123
1412
  @options[:list_count]
1124
1413
  ),
1125
- { per_page: @options[:select_page_height] }
1414
+ @options.merge({ per_page: @options[:select_page_height] })
1126
1415
  )
1127
1416
  return if filename.nil?
1128
1417
 
@@ -1135,11 +1424,37 @@ module MarkdownExec
1135
1424
  })
1136
1425
  end
1137
1426
 
1138
- def menu_export(data = menu_for_optparse)
1139
- data.map do |item|
1140
- item.delete(:procname)
1141
- item
1142
- end.to_yaml
1427
+ # set the title of an FCB object based on its body if it is nil or empty
1428
+ def set_fcb_title(fcb)
1429
+ return unless fcb.title.nil? || fcb.title.empty?
1430
+
1431
+ fcb.title = (fcb&.body || []).join(' ').gsub(/ +/, ' ')[0..64]
1432
+ end
1433
+
1434
+ def start_fenced_block(opts, line, headings, fenced_start_extended_regex)
1435
+ fcb_title_groups = line.match(fenced_start_extended_regex).named_captures.sym_keys
1436
+ rest = fcb_title_groups.fetch(:rest, '')
1437
+
1438
+ fcb = FCB.new
1439
+ fcb.headings = headings
1440
+ fcb.oname = fcb.dname = fcb_title_groups.fetch(:name, '')
1441
+ fcb.shell = fcb_title_groups.fetch(:shell, '')
1442
+ fcb.title = fcb_title_groups.fetch(:name, '')
1443
+ fcb.body = []
1444
+ fcb.reqs, fcb.wraps =
1445
+ ArrayUtil.partition_by_predicate(rest.scan(/\+[^\s]+/).map do |req|
1446
+ req[1..-1]
1447
+ end) do |name|
1448
+ !name.match(Regexp.new(opts[:block_name_wrapper_match]))
1449
+ end
1450
+ fcb.call = rest.match(Regexp.new(opts[:block_calls_scan]))&.to_a&.first
1451
+ fcb.stdin = if (tn = rest.match(/<(?<type>\$)?(?<name>[A-Za-z_-]\S+)/))
1452
+ tn.named_captures.sym_keys
1453
+ end
1454
+ fcb.stdout = if (tn = rest.match(/>(?<type>\$)?(?<name>[A-Za-z_\-.\w]+)/))
1455
+ tn.named_captures.sym_keys
1456
+ end
1457
+ fcb
1143
1458
  end
1144
1459
 
1145
1460
  def tab_completions(data = menu_for_optparse)
@@ -1148,6 +1463,77 @@ module MarkdownExec
1148
1463
  end.compact
1149
1464
  end
1150
1465
 
1466
+ def tty_prompt_without_disabled_symbol
1467
+ TTY::Prompt.new(interrupt: :exit, symbols: { cross: ' ' })
1468
+ end
1469
+
1470
+ ##
1471
+ # Updates the hierarchy of document headings based on the given line and existing headings.
1472
+ # The function uses regular expressions specified in the `opts` to identify different levels of headings.
1473
+ #
1474
+ # @param line [String] The line of text to examine for heading content.
1475
+ # @param headings [Array<String>] The existing list of document headings.
1476
+ # @param opts [Hash] A hash containing options for regular expression matches for different heading levels.
1477
+ #
1478
+ # @option opts [String] :heading1_match Regular expression for matching first-level headings.
1479
+ # @option opts [String] :heading2_match Regular expression for matching second-level headings.
1480
+ # @option opts [String] :heading3_match Regular expression for matching third-level headings.
1481
+ #
1482
+ # @return [Array<String>] Updated list of headings.
1483
+ def update_document_headings(line, headings, opts)
1484
+ if (lm = line.match(Regexp.new(opts[:heading3_match])))
1485
+ [headings[0], headings[1], lm[:name]]
1486
+ elsif (lm = line.match(Regexp.new(opts[:heading2_match])))
1487
+ [headings[0], lm[:name]]
1488
+ elsif (lm = line.match(Regexp.new(opts[:heading1_match])))
1489
+ [lm[:name]]
1490
+ else
1491
+ headings
1492
+ end
1493
+ end
1494
+
1495
+ ##
1496
+ # Processes an individual line within a loop, updating headings and handling fenced code blocks.
1497
+ # This function is designed to be called within a loop that iterates through each line of a document.
1498
+ #
1499
+ # @param line [String] The current line being processed.
1500
+ # @param state [Hash] The current state of the parser, including flags and data related to the processing.
1501
+ # @param opts [Hash] A hash containing various options for line and block processing.
1502
+ # @param selected_messages [Array<String>] Accumulator for lines or messages that are subject to further processing.
1503
+ # @param block [Proc] An optional block for further processing or transformation of lines.
1504
+ #
1505
+ # @option state [Array<String>] :headings Current headings to be updated based on the line.
1506
+ # @option state [Regexp] :fenced_start_and_end_regex Regular expression to match the start and end of a fenced block.
1507
+ # @option state [Boolean] :in_fenced_block Flag indicating whether the current line is inside a fenced block.
1508
+ # @option state [Object] :fcb An object representing the current fenced code block being processed.
1509
+ #
1510
+ # @option opts [Boolean] :menu_blocks_with_headings Flag indicating whether to update headings while processing.
1511
+ #
1512
+ # @return [Void] The function modifies the `state` and `selected_messages` arguments in place.
1513
+ ##
1514
+ def update_line_and_block_state(line, state, opts, selected_messages, &block)
1515
+ if opts[:menu_blocks_with_headings]
1516
+ state[:headings] =
1517
+ update_document_headings(line, state[:headings], opts)
1518
+ end
1519
+
1520
+ if line.match(state[:fenced_start_and_end_regex])
1521
+ if state[:in_fenced_block]
1522
+ process_fenced_block(state[:fcb], opts, selected_messages, &block)
1523
+ state[:in_fenced_block] = false
1524
+ else
1525
+ state[:fcb] =
1526
+ start_fenced_block(opts, line, state[:headings],
1527
+ state[:fenced_start_extended_regex])
1528
+ state[:in_fenced_block] = true
1529
+ end
1530
+ elsif state[:in_fenced_block] && state[:fcb].body
1531
+ state[:fcb].body += [line.chomp]
1532
+ else
1533
+ process_line(line, opts, selected_messages, &block)
1534
+ end
1535
+ end
1536
+
1151
1537
  # :reek:BooleanParameter
1152
1538
  # :reek:ControlParameter
1153
1539
  def update_options(opts = {}, over: true)
@@ -1159,7 +1545,30 @@ module MarkdownExec
1159
1545
  @options
1160
1546
  end
1161
1547
 
1162
- def write_command_file(call_options, required_blocks)
1548
+ ## Handles the menu interaction and returns selected block name and option state
1549
+ #
1550
+ def wait_for_user_selection(blocks_in_file, blocks_menu, default, opts)
1551
+ pt = opts[:prompt_select_block].to_s
1552
+ bm = prepare_blocks_menu(blocks_menu, opts)
1553
+ return [nil, :exit] if bm.count.zero?
1554
+
1555
+ obj = select_option_with_metadata(pt, bm, opts.merge(
1556
+ default: default,
1557
+ per_page: opts[:select_page_height]
1558
+ ))
1559
+ case obj.fetch(:option, nil)
1560
+ when opts[:menu_option_exit_name]
1561
+ [nil, :exit]
1562
+ when opts[:menu_option_back_name]
1563
+ [obj, :back]
1564
+ else
1565
+ label_block = blocks_in_file.find { |fcb| fcb.dname == obj[:selected] }
1566
+ [label_block.oname, :continue]
1567
+ end
1568
+ end
1569
+
1570
+ # Handles the core logic for generating the command file's metadata and content.
1571
+ def write_command_file(call_options, required_lines)
1163
1572
  return unless call_options[:save_executed_script]
1164
1573
 
1165
1574
  time_now = Time.now.utc
@@ -1174,24 +1583,113 @@ module MarkdownExec
1174
1583
  @options[:saved_filespec] =
1175
1584
  File.join opts[:saved_script_folder], opts[:saved_script_filename]
1176
1585
 
1177
- dirname = File.dirname(@options[:saved_filespec])
1178
- FileUtils.mkdir_p dirname
1179
1586
  shebang = if @options[:shebang]&.present?
1180
1587
  "#{@options[:shebang]} #{@options[:shell]}\n"
1181
1588
  else
1182
1589
  ''
1183
1590
  end
1184
1591
 
1185
- File.write(@options[:saved_filespec], shebang +
1186
- "# file_name: #{opts[:filename]}\n" \
1187
- "# block_name: #{opts[:block_name]}\n" \
1188
- "# time: #{time_now}\n" \
1189
- "#{required_blocks.flatten.join("\n")}\n")
1190
- return if @options[:saved_script_chmod].zero?
1592
+ content = shebang +
1593
+ "# file_name: #{opts[:filename]}\n" \
1594
+ "# block_name: #{opts[:block_name]}\n" \
1595
+ "# time: #{time_now}\n" \
1596
+ "#{required_lines.flatten.join("\n")}\n"
1597
+
1598
+ create_and_write_file_with_permissions(@options[:saved_filespec], content,
1599
+ @options[:saved_script_chmod])
1600
+ end
1601
+
1602
+ def write_execution_output_to_file
1603
+ FileUtils.mkdir_p File.dirname(@options[:logged_stdout_filespec])
1604
+
1605
+ ol = ["-STDOUT-\n"]
1606
+ ol += @execute_files&.fetch(EF_STDOUT, [])
1607
+ ol += ["\n-STDERR-\n"]
1608
+ ol += @execute_files&.fetch(EF_STDERR, [])
1609
+ ol += ["\n-STDIN-\n"]
1610
+ ol += @execute_files&.fetch(EF_STDIN, [])
1611
+ ol += ["\n"]
1612
+ File.write(@options[:logged_stdout_filespec], ol.join)
1613
+ end
1191
1614
 
1192
- File.chmod @options[:saved_script_chmod], @options[:saved_filespec]
1615
+ # Writes required code blocks to a temporary file and sets an environment variable with its path.
1616
+ #
1617
+ # @param block_name [String] The name of the block to collect code for.
1618
+ # @param opts [Hash] Additional options for collecting code.
1619
+ # @note Sets the 'MDE_LINK_REQUIRED_FILE' environment variable to the temporary file path.
1620
+ def write_required_blocks_to_temp_file(mdoc, block_name, opts = {})
1621
+ code_blocks = (read_required_blocks_from_temp_file +
1622
+ mdoc.collect_recursively_required_code(
1623
+ block_name,
1624
+ opts: opts
1625
+ )[:code]).join("\n")
1626
+
1627
+ Dir::Tmpname.create(self.class.to_s) do |path|
1628
+ pp path
1629
+ File.write(path, code_blocks)
1630
+ ENV['MDE_LINK_REQUIRED_FILE'] = path
1631
+ end
1193
1632
  end
1194
1633
  end # class MarkParse
1195
1634
  end # module MarkdownExec
1196
1635
 
1197
- require 'minitest/autorun' if $PROGRAM_NAME == __FILE__
1636
+ if $PROGRAM_NAME == __FILE__
1637
+ require 'bundler/setup'
1638
+ Bundler.require(:default)
1639
+
1640
+ require 'minitest/autorun'
1641
+
1642
+ module MarkdownExec
1643
+ class TestMarkParse < Minitest::Test
1644
+ require 'mocha/minitest'
1645
+
1646
+ def test_calling_execute_approved_block_calls_command_execute_with_argument_args_value
1647
+ pigeon = 'E'
1648
+ obj = { pass_args: pigeon }
1649
+
1650
+ c = MarkdownExec::MarkParse.new
1651
+
1652
+ # Expect that method command_execute is called with argument args having value pigeon
1653
+ c.expects(:command_execute).with(
1654
+ obj,
1655
+ '',
1656
+ args: pigeon
1657
+ )
1658
+
1659
+ # Call method execute_approved_block
1660
+ c.execute_approved_block(obj, [])
1661
+ end
1662
+
1663
+ def setup
1664
+ @mark_parse = MarkdownExec::MarkParse.new
1665
+ end
1666
+
1667
+ def test_set_fcb_title
1668
+ # sample input and output data for testing set_fcb_title method
1669
+ input_output_data = [
1670
+ {
1671
+ input: FCB.new(title: nil, body: ["puts 'Hello, world!'"]),
1672
+ output: "puts 'Hello, world!'"
1673
+ },
1674
+ {
1675
+ input: FCB.new(title: '', body: ['def add(x, y)', ' x + y', 'end']),
1676
+ output: 'def add(x, y) x + y end'
1677
+ },
1678
+ {
1679
+ input: FCB.new(title: 'foo', body: %w[bar baz]),
1680
+ output: 'foo' # expect the title to remain unchanged
1681
+ }
1682
+ ]
1683
+
1684
+ # iterate over the input and output data and
1685
+ # assert that the method sets the title as expected
1686
+ input_output_data.each do |data|
1687
+ input = data[:input]
1688
+ output = data[:output]
1689
+ @mark_parse.set_fcb_title(input)
1690
+ assert_equal output, input.title
1691
+ end
1692
+ end
1693
+ end
1694
+ end
1695
+ end