markdown_exec 1.6 → 1.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,2796 @@
1
+ # encoding=utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'English'
5
+ require 'clipboard'
6
+ require 'fileutils'
7
+ require 'open3'
8
+ require 'optparse'
9
+ require 'set'
10
+ require 'shellwords'
11
+ require 'tmpdir'
12
+ require 'tty-prompt'
13
+ require 'yaml'
14
+
15
+ require_relative 'array'
16
+ require_relative 'array_util'
17
+ require_relative 'block_label'
18
+ require_relative 'block_types'
19
+ require_relative 'cached_nested_file_reader'
20
+ require_relative 'constants'
21
+ require_relative 'directory_searcher'
22
+ require_relative 'exceptions'
23
+ require_relative 'fcb'
24
+ require_relative 'filter'
25
+ require_relative 'fout'
26
+ require_relative 'hash'
27
+ require_relative 'mdoc'
28
+ require_relative 'string_util'
29
+
30
+ class String
31
+ # Checks if the string is not empty.
32
+ # @return [Boolean] Returns true if the string is not empty, false otherwise.
33
+ def non_empty?
34
+ !empty?
35
+ end
36
+ end
37
+
38
+ # This module provides methods for compacting and converting data structures.
39
+ module CompactionHelpers
40
+ # Converts an array of key-value pairs into a hash, applying compaction to the values.
41
+ # Each value is processed by `compact_hash` to remove ineligible elements.
42
+ #
43
+ # @param array [Array] The array of key-value pairs to be converted.
44
+ # @return [Hash] A hash with keys from the array and compacted values.
45
+ def compact_and_convert_array_to_hash(array)
46
+ array.transform_values do |value|
47
+ compact_hash(value)
48
+ end
49
+ end
50
+
51
+ # Compacts a hash by removing ineligible elements.
52
+ # It filters out nil, empty arrays, empty hashes, and empty strings from its values.
53
+ # It also removes entries with :random as the key.
54
+ #
55
+ # @param hash [Hash] The hash to be compacted.
56
+ # @return [Hash] A compacted version of the input hash.
57
+ def compact_hash(hash)
58
+ hash.map do |key, value|
59
+ next if value_ineligible?(value) || key == :random
60
+
61
+ [key, value]
62
+ end.compact.to_h
63
+ end
64
+
65
+ # Converts a hash into another hash with indexed keys, applying compaction to the values.
66
+ # The keys are indexed, and the values are compacted using `compact_and_convert_array_to_hash`.
67
+ #
68
+ # @param hash [Hash] The hash to be converted and compacted.
69
+ # @return [Hash] A hash with indexed keys and the compacted original values.
70
+ def compact_and_index_hash(hash)
71
+ compact_and_convert_array_to_hash(hash.map.with_index do |value, index|
72
+ [index, value]
73
+ end.to_h)
74
+ end
75
+
76
+ private
77
+
78
+ # Determines if a value is ineligible for inclusion in a compacted hash.
79
+ # Ineligible values are nil, empty arrays, empty hashes, and empty strings.
80
+ #
81
+ # @param value [Object] The value to be checked.
82
+ # @return [Boolean] True if the value is ineligible, false otherwise.
83
+ def value_ineligible?(value)
84
+ [nil, [], {}, ''].include?(value)
85
+ end
86
+ end
87
+
88
+ module MarkdownExec
89
+ class DebugHelper
90
+ # Class-level variable to store history of printed messages
91
+ @@printed_messages = Set.new
92
+
93
+ # Outputs a warning message only once for a unique set of inputs
94
+ #
95
+ # @param str [Array] Variable number of arguments to be printed
96
+ def self.d(*str)
97
+ return if @@printed_messages.include?(str)
98
+
99
+ warn(*str)
100
+ @@printed_messages.add(str)
101
+ end
102
+ end
103
+
104
+ class HashDelegator
105
+ attr_accessor :most_recent_loaded_filename, :pass_args, :run_state
106
+
107
+ include CompactionHelpers
108
+
109
+ def initialize(delegate_object = {})
110
+ @delegate_object = delegate_object
111
+ @prompt = tty_prompt_without_disabled_symbol
112
+
113
+ @most_recent_loaded_filename = nil
114
+ @pass_args = []
115
+ @run_state = OpenStruct.new(
116
+ link_history: []
117
+ )
118
+ @fout = FOut.new(@delegate_object) ### slice only relevant keys
119
+
120
+ @process_mutex = Mutex.new
121
+ @process_cv = ConditionVariable.new
122
+ end
123
+
124
+ # private
125
+
126
+ # def [](key)
127
+ # @delegate_object[key]
128
+ # end
129
+
130
+ # def []=(key, value)
131
+ # @delegate_object[key] = value
132
+ # end
133
+
134
+ # Modifies the provided menu blocks array by adding 'Back' and 'Exit' options,
135
+ # along with initial and final dividers, based on the delegate object's configuration.
136
+ #
137
+ # @param menu_blocks [Array] The array of menu block elements to be modified.
138
+ def add_menu_chrome_blocks!(menu_blocks)
139
+ return unless @delegate_object[:menu_link_format].present?
140
+
141
+ add_back_option(menu_blocks) if should_add_back_option?
142
+ add_exit_option(menu_blocks) if @delegate_object[:menu_with_exit]
143
+ add_dividers(menu_blocks)
144
+ end
145
+
146
+ private
147
+
148
+ def should_add_back_option?
149
+ @delegate_object[:menu_with_back] && history_env_state_exist?
150
+ end
151
+
152
+ def add_back_option(menu_blocks)
153
+ append_chrome_block(menu_blocks, MenuState::BACK)
154
+ end
155
+
156
+ def add_exit_option(menu_blocks)
157
+ append_chrome_block(menu_blocks, MenuState::EXIT)
158
+ end
159
+
160
+ def add_dividers(menu_blocks)
161
+ append_divider(menu_blocks, :initial)
162
+ append_divider(menu_blocks, :final)
163
+ end
164
+
165
+ public
166
+
167
+ # Appends a chrome block, which is a menu option for Back or Exit
168
+ #
169
+ # @param all_blocks [Array] The current blocks in the menu
170
+ # @param type [Symbol] The type of chrome block to add (:back or :exit)
171
+ def append_chrome_block(menu_blocks, type)
172
+ case type
173
+ when MenuState::BACK
174
+ state = history_state_partition
175
+ @hs_curr = state[:unit]
176
+ @hs_rest = state[:rest]
177
+ option_name = @delegate_object[:menu_option_back_name]
178
+ insert_at_top = @delegate_object[:menu_back_at_top]
179
+ when MenuState::EXIT
180
+ option_name = @delegate_object[:menu_option_exit_name]
181
+ insert_at_top = @delegate_object[:menu_exit_at_top]
182
+ end
183
+
184
+ formatted_name = format(@delegate_object[:menu_link_format],
185
+ safeval(option_name))
186
+ chrome_block = FCB.new(
187
+ chrome: true,
188
+
189
+ # dname: formatted_name.send(@delegate_object[:menu_link_color].to_sym),
190
+ dname: HashDelegator.new(@delegate_object).string_send_color(
191
+ formatted_name, :menu_link_color
192
+ ),
193
+ #dname: @delegate_object.string_send_color(formatted_name, :menu_link_color),
194
+
195
+ oname: formatted_name
196
+ )
197
+
198
+ if insert_at_top
199
+ menu_blocks.unshift(chrome_block)
200
+ else
201
+ menu_blocks.push(chrome_block)
202
+ end
203
+ end
204
+
205
+ # Appends a formatted divider to the specified position in a menu block array.
206
+ # The method checks for the presence of formatting options before appending.
207
+ #
208
+ # @param menu_blocks [Array] The array of menu block elements.
209
+ # @param position [Symbol] The position to insert the divider (:initial or :final).
210
+ def append_divider(menu_blocks, position)
211
+ return unless divider_formatting_present?(position)
212
+
213
+ divider = create_divider(position)
214
+ position == :initial ? menu_blocks.unshift(divider) : menu_blocks.push(divider)
215
+ end
216
+
217
+ # private
218
+
219
+ def divider_formatting_present?(position)
220
+ divider_key = position == :initial ? :menu_initial_divider : :menu_final_divider
221
+ @delegate_object[:menu_divider_format].present? && @delegate_object[divider_key].present?
222
+ end
223
+
224
+ def create_divider(position)
225
+ divider_key = position == :initial ? :menu_initial_divider : :menu_final_divider
226
+ oname = format(@delegate_object[:menu_divider_format],
227
+ safeval(@delegate_object[divider_key]))
228
+
229
+ FCB.new(
230
+ chrome: true,
231
+ disabled: '',
232
+ dname: string_send_color(oname, :menu_divider_color),
233
+ oname: oname
234
+ )
235
+ end
236
+
237
+ # Execute a code block after approval and provide user interaction options.
238
+ #
239
+ # This method displays required code blocks, asks for user approval, and
240
+ # executes the code block if approved. It also allows users to copy the
241
+ # code to the clipboard or save it to a file.
242
+ #
243
+ # @param opts [Hash] Options hash containing configuration settings.
244
+ # @param mdoc [YourMDocClass] An instance of the MDoc class.
245
+ #
246
+ def approve_and_execute_block(selected, mdoc)
247
+ if selected.fetch(:shell, '') == BlockType::LINK
248
+ handle_link_block(selected.fetch(:body, ''), mdoc, selected)
249
+ elsif @menu_user_clicked_back_link
250
+ handle_back_link
251
+ elsif selected[:shell] == BlockType::OPTS
252
+ handle_opts_block(selected, @menu_base_options)
253
+ else
254
+ handle_generic_block(mdoc, selected)
255
+ end
256
+ end
257
+
258
+ # private
259
+
260
+ # Searches for the first element in a collection where the specified key matches a given value.
261
+ # This method is particularly useful for finding a specific hash-like object within an enumerable collection.
262
+ # If no match is found, it returns a specified default value.
263
+ #
264
+ # @param blocks [Enumerable] The collection of hash-like objects to search.
265
+ # @param key [Object] The key to search for in each element of the collection.
266
+ # @param value [Object] The value to match against each element's corresponding key value.
267
+ # @param default [Object, nil] The default value to return if no match is found (optional).
268
+ # @return [Object, nil] The first matching element or the default value if no match is found.
269
+ def block_find(blocks, key, value, default = nil)
270
+ blocks.find { |item| item[key] == value } || default
271
+ end
272
+
273
+ # Iterates through nested files to collect various types of blocks, including dividers, tasks, and others.
274
+ # The method categorizes blocks based on their type and processes them accordingly.
275
+ #
276
+ # @return [Array<FCB>] An array of FCB objects representing the blocks.
277
+ def blocks_from_nested_files
278
+ blocks = []
279
+ iter_blocks_from_nested_files do |btype, fcb|
280
+ process_block_based_on_type(blocks, btype, fcb)
281
+ end
282
+ blocks
283
+ rescue StandardError
284
+ error_handler('blocks_from_nested_files')
285
+ end
286
+
287
+ # private
288
+
289
+ def process_block_based_on_type(blocks, btype, fcb)
290
+ case btype
291
+ when :blocks
292
+ blocks.push(get_block_summary(fcb))
293
+ when :filter
294
+ %i[blocks line]
295
+ when :line
296
+ unless @delegate_object[:no_chrome]
297
+ create_and_add_chrome_blocks(blocks,
298
+ fcb)
299
+ end
300
+ end
301
+ end
302
+
303
+ # private
304
+
305
+ def cfile
306
+ @cfile ||= CachedNestedFileReader.new(
307
+ import_pattern: @delegate_object.fetch(:import_pattern) #, "^ *@import +(?<name>.+?) *$")
308
+ )
309
+ end
310
+
311
+ # Check whether the document exists and is readable
312
+ def check_file_existence(filename)
313
+ unless filename&.present?
314
+ @fout.fout 'No blocks found.'
315
+ return false
316
+ end
317
+
318
+ unless File.exist? filename
319
+ @fout.fout 'Document is missing.'
320
+ return false
321
+ end
322
+ true
323
+ end
324
+
325
+ def runtime_exception(exception_sym, name, items)
326
+ if @delegate_object[exception_sym] != 0
327
+ data = { name: name, detail: items.join(', ') }
328
+ warn(
329
+ format(
330
+ @delegate_object.fetch(:exception_format_name, "\n%{name}"),
331
+ data
332
+ ).send(@delegate_object.fetch(:exception_color_name, :red)) +
333
+ format(
334
+ @delegate_object.fetch(:exception_format_detail, " - %{detail}\n"),
335
+ data
336
+ ).send(@delegate_object.fetch(:exception_color_detail, :yellow))
337
+ )
338
+ end
339
+ return unless (@delegate_object[exception_sym]).positive?
340
+
341
+ exit @delegate_object[exception_sym]
342
+ end
343
+
344
+ # Collects required code lines based on the selected block and the delegate object's configuration.
345
+ # If the block type is VARS, it also sets environment variables based on the block's content.
346
+ #
347
+ # @param mdoc [YourMDocClass] An instance of the MDoc class.
348
+ # @param selected [Hash] The selected block.
349
+ # @return [Array<String>] Required code blocks as an array of lines.
350
+ def collect_required_code_lines(mdoc, selected)
351
+ set_environment_variables(selected) if selected[:shell] == BlockType::VARS
352
+
353
+ required = mdoc.collect_recursively_required_code(
354
+ @delegate_object[:block_name],
355
+ label_format_above: @delegate_object[:shell_code_label_format_above],
356
+ label_format_below: @delegate_object[:shell_code_label_format_below]
357
+ )
358
+ if required[:unmet_dependencies].present?
359
+ warn format_and_highlight_dependencies(required[:dependencies],
360
+ highlight: required[:unmet_dependencies])
361
+ runtime_exception(:runtime_exception_error_level,
362
+ 'unmet_dependencies, flag: runtime_exception_error_level', required[:unmet_dependencies])
363
+ elsif true
364
+ warn format_and_highlight_dependencies(required[:dependencies],
365
+ highlight: [@delegate_object[:block_name]])
366
+ end
367
+ read_required_blocks_from_temp_file + required[:code]
368
+ end
369
+
370
+ # private
371
+
372
+ def set_environment_variables(selected)
373
+ YAML.load(selected[:body].join("\n")).each do |key, value|
374
+ ENV[key] = value.to_s
375
+ next unless @delegate_object[:menu_vars_set_format].present?
376
+
377
+ formatted_string = format(@delegate_object[:menu_vars_set_format],
378
+ { key: key, value: value })
379
+ print string_send_color(formatted_string, :menu_vars_set_color)
380
+ end
381
+ end
382
+
383
+ def command_execute(command, args: [])
384
+ @run_state.files = Hash.new([])
385
+ @run_state.options = @delegate_object
386
+ @run_state.started_at = Time.now.utc
387
+
388
+ Open3.popen3(@delegate_object[:shell],
389
+ '-c', command,
390
+ @delegate_object[:filename],
391
+ *args) do |stdin, stdout, stderr, exec_thr|
392
+ handle_stream(stdout, ExecutionStreams::StdOut) do |line|
393
+ yield nil, line, nil, exec_thr if block_given?
394
+ end
395
+ handle_stream(stderr, ExecutionStreams::StdErr) do |line|
396
+ yield nil, nil, line, exec_thr if block_given?
397
+ end
398
+
399
+ in_thr = handle_stream($stdin, ExecutionStreams::StdIn) do |line|
400
+ stdin.puts(line)
401
+ yield line, nil, nil, exec_thr if block_given?
402
+ end
403
+
404
+ wait_for_stream_processing
405
+ exec_thr.join
406
+ sleep 0.1
407
+ in_thr.kill if in_thr&.alive?
408
+ end
409
+
410
+ @run_state.completed_at = Time.now.utc
411
+ rescue Errno::ENOENT => err
412
+ # Handle ENOENT error
413
+ @run_state.aborted_at = Time.now.utc
414
+ @run_state.error_message = err.message
415
+ @run_state.error = err
416
+ @run_state.files[ExecutionStreams::StdErr] += [@run_state.error_message]
417
+ @fout.fout "Error ENOENT: #{err.inspect}"
418
+ rescue SignalException => err
419
+ # Handle SignalException
420
+ @run_state.aborted_at = Time.now.utc
421
+ @run_state.error_message = 'SIGTERM'
422
+ @run_state.error = err
423
+ @run_state.files[ExecutionStreams::StdErr] += [@run_state.error_message]
424
+ @fout.fout "Error ENOENT: #{err.inspect}"
425
+ end
426
+
427
+ def command_or_user_selected_block(all_blocks, menu_blocks, default)
428
+ if @delegate_object[:block_name].present?
429
+ block = all_blocks.find do |item|
430
+ item[:oname] == @delegate_object[:block_name]
431
+ end
432
+ else
433
+ block_state = wait_for_user_selected_block(all_blocks, menu_blocks,
434
+ default)
435
+ block = block_state.block
436
+ state = block_state.state
437
+ end
438
+
439
+ SelectedBlockMenuState.new(block, state)
440
+ rescue StandardError
441
+ error_handler('command_or_user_selected_block')
442
+ end
443
+
444
+ def copy_to_clipboard(required_lines)
445
+ text = required_lines.flatten.join($INPUT_RECORD_SEPARATOR)
446
+ Clipboard.copy(text)
447
+ @fout.fout "Clipboard updated: #{required_lines.count} blocks," \
448
+ " #{required_lines.flatten.count} lines," \
449
+ " #{text.length} characters"
450
+ end
451
+
452
+ # Counts the number of fenced code blocks in a file.
453
+ # It reads lines from a file and counts occurrences of lines matching the fenced block regex.
454
+ # Assumes that every fenced block starts and ends with a distinct line (hence divided by 2).
455
+ #
456
+ # @return [Integer] The count of fenced code blocks in the file.
457
+ def count_blocks_in_filename
458
+ regex = Regexp.new(@delegate_object[:fenced_start_and_end_regex])
459
+ lines = cfile.readlines(@delegate_object[:filename])
460
+ count_matches_in_lines(lines, regex) / 2
461
+ end
462
+
463
+ # private
464
+
465
+ def count_matches_in_lines(lines, regex)
466
+ lines.count { |line| line.to_s.match(regex) }
467
+ end
468
+
469
+ # private
470
+
471
+ ##
472
+ # Creates and adds a formatted block to the blocks array based on the provided match and format options.
473
+ # @param blocks [Array] The array of blocks to add the new block to.
474
+ # @param match_data [MatchData] The match data containing named captures for formatting.
475
+ # @param format_option [String] The format string to be used for the new block.
476
+ # @param color_method [Symbol] The color method to apply to the block's display name.
477
+ def create_and_add_chrome_block(blocks, match_data, format_option,
478
+ color_method)
479
+ oname = format(format_option,
480
+ match_data.named_captures.transform_keys(&:to_sym))
481
+ blocks.push FCB.new(
482
+ chrome: true,
483
+ disabled: '',
484
+ dname: oname.send(color_method),
485
+ oname: oname
486
+ )
487
+ end
488
+
489
+ ##
490
+ # Processes lines within the file and converts them into blocks if they match certain criteria.
491
+ # @param blocks [Array] The array to append new blocks to.
492
+ # @param fcb [FCB] The file control block being processed.
493
+ # @param opts [Hash] Options containing configuration for line processing.
494
+ # @param use_chrome [Boolean] Indicates if the chrome styling should be applied.
495
+ def create_and_add_chrome_blocks(blocks, fcb)
496
+ match_criteria = [
497
+ { match: :menu_task_match, format: :menu_task_format,
498
+ color: :menu_task_color },
499
+ { match: :menu_divider_match, format: :menu_divider_format,
500
+ color: :menu_divider_color },
501
+ { match: :menu_note_match, format: :menu_note_format,
502
+ color: :menu_note_color }
503
+ ]
504
+ match_criteria.each do |criteria|
505
+ unless @delegate_object[criteria[:match]].present? &&
506
+ (mbody = fcb.body[0].match @delegate_object[criteria[:match]])
507
+ next
508
+ end
509
+
510
+ create_and_add_chrome_block(blocks, mbody, @delegate_object[criteria[:format]],
511
+ @delegate_object[criteria[:color]].to_sym)
512
+ break
513
+ end
514
+ end
515
+
516
+ # Creates a file at the specified path, writes the given content to it,
517
+ # and sets file permissions if required. Handles any errors encountered during the process.
518
+ #
519
+ # @param file_path [String] The path where the file will be created.
520
+ # @param content [String] The content to write into the file.
521
+ # @param chmod_value [Integer] The file permission value to set; skips if zero.
522
+ def create_and_write_file_with_permissions(file_path, content,
523
+ chmod_value)
524
+ create_directory_for_file(file_path)
525
+ write_file_content(file_path, content)
526
+ set_file_permissions(file_path, chmod_value) unless chmod_value.zero?
527
+ rescue StandardError
528
+ error_handler('create_and_write_file_with_permissions')
529
+ end
530
+
531
+ # private
532
+
533
+ def create_directory_for_file(file_path)
534
+ FileUtils.mkdir_p(File.dirname(file_path))
535
+ end
536
+
537
+ def write_file_content(file_path, content)
538
+ File.write(file_path, content)
539
+ end
540
+
541
+ def set_file_permissions(file_path, chmod_value)
542
+ File.chmod(chmod_value, file_path)
543
+ end
544
+
545
+ # Creates a temporary file, writes the provided code blocks into it,
546
+ # and sets an environment variable with the file path.
547
+ # @param code_blocks [String] Code blocks to write into the file.
548
+ def create_temp_file_with_code(code_blocks)
549
+ temp_file_path = create_temp_file
550
+ write_to_file(temp_file_path, code_blocks)
551
+ set_environment_variable(temp_file_path)
552
+ end
553
+
554
+ # private
555
+
556
+ def create_temp_file
557
+ Dir::Tmpname.create(self.class.to_s) { |path| path }
558
+ end
559
+
560
+ def write_to_file(path, content)
561
+ File.write(path, content)
562
+ end
563
+
564
+ def set_environment_variable(path)
565
+ ENV['MDE_LINK_REQUIRED_FILE'] = path
566
+ end
567
+
568
+ # Updates the title of an FCB object from its body content if the title is nil or empty.
569
+ def default_block_title_from_body(fcb)
570
+ return unless fcb.title.nil? || fcb.title.empty?
571
+
572
+ fcb.derive_title_from_body
573
+ end
574
+
575
+ # delete the current line if it is empty and the previous is also empty
576
+ def delete_consecutive_blank_lines!(blocks_menu)
577
+ blocks_menu.process_and_conditionally_delete! do |prev_item, current_item, _next_item|
578
+ prev_item&.fetch(:chrome, nil) && !prev_item&.fetch(:oname).present? &&
579
+ current_item&.fetch(:chrome, nil) && !current_item&.fetch(:oname).present?
580
+ end
581
+ end
582
+
583
+ # Deletes a temporary file specified by an environment variable.
584
+ # Checks if the file exists before attempting to delete it and clears the environment variable afterward.
585
+ # Any errors encountered during deletion are handled gracefully.
586
+ def delete_required_temp_file
587
+ temp_blocks_file_path = fetch_temp_blocks_file_path
588
+
589
+ return if temp_blocks_file_path.nil? || temp_blocks_file_path.empty?
590
+
591
+ safely_remove_file(temp_blocks_file_path)
592
+ clear_required_file
593
+ rescue StandardError
594
+ error_handler('delete_required_temp_file')
595
+ end
596
+
597
+ # private
598
+
599
+ def fetch_temp_blocks_file_path
600
+ ENV.fetch('MDE_LINK_REQUIRED_FILE', nil)
601
+ end
602
+
603
+ def safely_remove_file(path)
604
+ FileUtils.rm_f(path)
605
+ end
606
+
607
+ # Determines the state of a selected block in the menu based on the selected option.
608
+ # It categorizes the selected option into either EXIT, BACK, or CONTINUE state.
609
+ #
610
+ # @param selected_option [Hash] The selected menu option.
611
+ # @return [SelectedBlockMenuState] An object representing the state of the selected block.
612
+ def determine_block_state(selected_option)
613
+ option_name = selected_option.fetch(:oname, nil)
614
+ if option_name == menu_chrome_formatted_option(:menu_option_exit_name)
615
+ return SelectedBlockMenuState.new(nil,
616
+ MenuState::EXIT)
617
+ end
618
+ if option_name == menu_chrome_formatted_option(:menu_option_back_name)
619
+ return SelectedBlockMenuState.new(selected_option,
620
+ MenuState::BACK)
621
+ end
622
+
623
+ SelectedBlockMenuState.new(selected_option, MenuState::CONTINUE)
624
+ end
625
+
626
+ # Displays the required lines of code with color formatting for the preview section.
627
+ # It wraps the code lines between a formatted header and tail.
628
+ #
629
+ # @param required_lines [Array<String>] The lines of code to be displayed.
630
+ def display_required_code(required_lines)
631
+ output_color_formatted(:script_preview_head,
632
+ :script_preview_frame_color)
633
+ required_lines.each { |cb| @fout.fout cb }
634
+ output_color_formatted(:script_preview_tail,
635
+ :script_preview_frame_color)
636
+ end
637
+
638
+ # private
639
+
640
+ def output_color_formatted(data_sym, color_sym)
641
+ formatted_string = string_send_color(@delegate_object[data_sym],
642
+ color_sym)
643
+ @fout.fout formatted_string
644
+ end
645
+
646
+ def error_handler(name = '', opts = {})
647
+ Exceptions.error_handler(
648
+ "HashDelegator.#{name} -- #{$!}",
649
+ opts
650
+ )
651
+ end
652
+
653
+ # public
654
+
655
+ # Executes a block of code that has been approved for execution.
656
+ # It sets the script block name, writes command files if required, and handles the execution
657
+ # including output formatting and summarization.
658
+ #
659
+ # @param required_lines [Array<String>] The lines of code to be executed.
660
+ # @param selected [FCB] The selected functional code block object.
661
+ def execute_approved_block(required_lines = [], _selected = FCB.new)
662
+ # set_script_block_name(selected)
663
+ write_command_file_if_needed(required_lines)
664
+ format_and_execute_command(required_lines)
665
+ post_execution_process
666
+ end
667
+
668
+ # private
669
+
670
+ def set_script_block_name(selected)
671
+ @run_state.script_block_name = selected[:oname]
672
+ end
673
+
674
+ def write_command_file_if_needed(lines)
675
+ write_command_file(lines) if @delegate_object[:save_executed_script]
676
+ end
677
+
678
+ def format_and_execute_command(lines)
679
+ formatted_command = lines.flatten.join("\n")
680
+ @fout.fout fetch_color(data_sym: :script_execution_head,
681
+ color_sym: :script_execution_frame_color)
682
+ command_execute(formatted_command, args: @pass_args)
683
+ @fout.fout fetch_color(data_sym: :script_execution_tail,
684
+ color_sym: :script_execution_frame_color)
685
+ end
686
+
687
+ def post_execution_process
688
+ initialize_and_save_execution_output
689
+ output_execution_summary
690
+ output_execution_result
691
+ end
692
+
693
+ # Retrieves a specific data symbol from the delegate object, converts it to a string,
694
+ # and applies a color style based on the specified color symbol.
695
+ #
696
+ # @param default [String] The default value if the data symbol is not found.
697
+ # @param data_sym [Symbol] The symbol key to fetch data from the delegate object.
698
+ # @param color_sym [Symbol] The symbol key to fetch the color option for styling.
699
+ # @return [String] The color-styled string.
700
+ def fetch_color(default: '',
701
+ data_sym: :execution_report_preview_head,
702
+ color_sym: :execution_report_preview_frame_color)
703
+ data_string = @delegate_object.fetch(data_sym, default).to_s
704
+ string_send_color(data_string, color_sym)
705
+ end
706
+
707
+ # Formats a string based on a given context and applies color styling to it.
708
+ # It retrieves format and color information from the delegate object and processes accordingly.
709
+ #
710
+ # @param default [String] The default value if the format symbol is not found (unused in current implementation).
711
+ # @param context [Hash] Contextual data used for string formatting.
712
+ # @param format_sym [Symbol] Symbol key to fetch the format string from the delegate object.
713
+ # @param color_sym [Symbol] Symbol key to fetch the color option for string styling.
714
+ # @return [String] The formatted and color-styled string.
715
+ def format_references_send_color(default: '', context: {},
716
+ format_sym: :output_execution_label_format,
717
+ color_sym: :execution_report_preview_frame_color)
718
+ formatted_string = format(@delegate_object.fetch(format_sym, ''),
719
+ context).to_s
720
+ string_send_color(formatted_string, color_sym)
721
+ end
722
+
723
+ # Formats and returns the execution streams (like stdin, stdout, stderr) for a given key.
724
+ # It concatenates the array of strings found under the specified key in the run_state's files.
725
+ #
726
+ # @param key [Symbol] The key corresponding to the desired execution stream.
727
+ # @return [String] A concatenated string of the execution stream's contents.
728
+ def format_execution_streams(key)
729
+ files = @run_state.files || {}
730
+ files.fetch(key, []).join
731
+ end
732
+
733
+ # Processes a block to generate its summary, modifying its attributes based on various matching criteria.
734
+ # It handles special formatting for bash blocks, extracting and setting properties like call, stdin, stdout, and dname.
735
+ #
736
+ # @param fcb [Object] An object representing a functional code block.
737
+ # @return [Object] The modified functional code block with updated summary attributes.
738
+ def get_block_summary(fcb)
739
+ return fcb unless @delegate_object[:bash]
740
+
741
+ fcb.call = fcb.title.match(Regexp.new(@delegate_object[:block_calls_scan]))&.fetch(1, nil)
742
+ titlexcall = fcb.call ? fcb.title.sub("%#{fcb.call}", '') : fcb.title
743
+ bm = extract_named_captures_from_option(titlexcall,
744
+ @delegate_object[:block_name_match])
745
+
746
+ fcb.stdin = extract_named_captures_from_option(titlexcall,
747
+ @delegate_object[:block_stdin_scan])
748
+ fcb.stdout = extract_named_captures_from_option(titlexcall,
749
+ @delegate_object[:block_stdout_scan])
750
+
751
+ shell_color_option = SHELL_COLOR_OPTIONS[fcb[:shell]]
752
+ fcb.title = fcb.oname = bm && bm[1] ? bm[:title] : titlexcall
753
+ fcb.dname = apply_shell_color_option(fcb.oname, shell_color_option)
754
+
755
+ fcb
756
+ end
757
+
758
+ # private
759
+
760
+ # Applies shell color options to the given string if applicable.
761
+ #
762
+ # @param name [String] The name to potentially colorize.
763
+ # @param shell_color_option [Symbol, nil] The shell color option to apply.
764
+ # @return [String] The colorized or original name string.
765
+ def apply_shell_color_option(name, shell_color_option)
766
+ if shell_color_option && @delegate_object[shell_color_option].present?
767
+ string_send_color(name, shell_color_option)
768
+ else
769
+ name
770
+ end
771
+ end
772
+
773
+ # This method handles the back-link operation in the Markdown execution context.
774
+ # It updates the history state and prepares to load the next block.
775
+ #
776
+ # @return [LoadFileNextBlock] An object indicating the action to load the next block.
777
+ def handle_back_link
778
+ history_state_pop
779
+ LoadFileNextBlock.new(LoadFile::Load, '')
780
+ end
781
+
782
+ # private
783
+ # Updates the delegate object's state based on the provided block state.
784
+ # It sets the block name and determines if the user clicked the back link in the menu.
785
+ #
786
+ # @param block_state [Object] An object representing the state of a block in the menu.
787
+ def handle_block_state(block_state)
788
+ unless [MenuState::BACK,
789
+ MenuState::CONTINUE].include?(block_state.state)
790
+ return
791
+ end
792
+
793
+ @delegate_object[:block_name] = block_state.block[:dname]
794
+ @menu_user_clicked_back_link = block_state.state == MenuState::BACK
795
+ end
796
+
797
+ # This method is responsible for handling the execution of generic blocks in a markdown document.
798
+ # It collects the required code lines from the document and, depending on the configuration,
799
+ # may display the code for user approval before execution. It then executes the approved block.
800
+ #
801
+ # @param mdoc [Object] The markdown document object containing code blocks.
802
+ # @param selected [Hash] The selected item from the menu to be executed.
803
+ # @return [LoadFileNextBlock] An object indicating whether to load the next block or reuse the current one.
804
+ def handle_generic_block(mdoc, selected)
805
+ required_lines = collect_required_code_lines(mdoc, selected)
806
+ output_or_approval = @delegate_object[:output_script] || @delegate_object[:user_must_approve]
807
+ display_required_code(required_lines) if output_or_approval
808
+ allow_execution = @delegate_object[:user_must_approve] ? prompt_for_user_approval(required_lines) : true
809
+ execute_approved_block(required_lines, selected) if allow_execution
810
+
811
+ LoadFileNextBlock.new(LoadFile::Reuse, '')
812
+ end
813
+
814
+ # Handles the processing of a link block in Markdown Execution.
815
+ # It loads YAML data from the body content, pushes the state to history,
816
+ # sets environment variables, and decides on the next block to load.
817
+ #
818
+ # @param body [Array<String>] The body content as an array of strings.
819
+ # @param mdoc [Object] Markdown document object.
820
+ # @param selected [Boolean] Selected state.
821
+ # @return [LoadFileNextBlock] Object indicating the next action for file loading.
822
+ def handle_link_block(body, mdoc, selected)
823
+ data = parse_yaml_data_from_body(body)
824
+ data_file = data['file']
825
+ return LoadFileNextBlock.new(LoadFile::Reuse, '') unless data_file
826
+
827
+ history_state_push(mdoc, data_file, selected)
828
+ set_environment_variables(data['vars'])
829
+
830
+ LoadFileNextBlock.new(LoadFile::Load, data['block'] || '')
831
+ end
832
+
833
+ # private
834
+
835
+ def parse_yaml_data_from_body(body)
836
+ body.any? ? YAML.load(body.join("\n")) : {}
837
+ end
838
+
839
+ def set_environment_variables(vars)
840
+ vars ||= []
841
+ vars.each { |key, value| ENV[key] = value.to_s }
842
+ end
843
+
844
+ # Processes YAML data from the selected menu item, updating delegate objects and optionally printing formatted output.
845
+ # @param selected [Hash] Selected item from the menu containing a YAML body.
846
+ # @param tgt2 [Hash, nil] An optional target hash to update with YAML data.
847
+ # @return [LoadFileNextBlock] An instance indicating the next action for loading files.
848
+ def handle_opts_block(selected, tgt2 = nil)
849
+ data = YAML.load(selected[:body].join("\n"))
850
+ (data || []).each do |key, value|
851
+ update_delegate_and_target(key, value, tgt2)
852
+ if @delegate_object[:menu_opts_set_format].present?
853
+ print_formatted_option(key,
854
+ value)
855
+ end
856
+ end
857
+ LoadFileNextBlock.new(LoadFile::Reuse, '')
858
+ end
859
+
860
+ # private
861
+
862
+ def update_delegate_and_target(key, value, tgt2)
863
+ sym_key = key.to_sym
864
+ @delegate_object[sym_key] = value
865
+ tgt2[sym_key] = value if tgt2
866
+ end
867
+
868
+ def print_formatted_option(key, value)
869
+ formatted_str = format(@delegate_object[:menu_opts_set_format],
870
+ { key: key, value: value })
871
+ print string_send_color(formatted_str, :menu_opts_set_color)
872
+ end
873
+
874
+ def handle_stream(stream, file_type, swap: false)
875
+ @process_mutex.synchronize do
876
+ Thread.new do
877
+ stream.each_line do |line|
878
+ line.strip!
879
+ @run_state.files[file_type] << line
880
+
881
+ if @delegate_object[:output_stdout]
882
+ # print line
883
+ puts line
884
+ end
885
+
886
+ yield line if block_given?
887
+ end
888
+ rescue IOError
889
+ # Handle IOError
890
+ ensure
891
+ @process_cv.signal
892
+ end
893
+ end
894
+ end
895
+
896
+ def wait_for_stream_processing
897
+ @process_mutex.synchronize do
898
+ @process_cv.wait(@process_mutex)
899
+ end
900
+ end
901
+
902
+ # Partitions the history state from the environment variable based on the document separator.
903
+ # @return [Hash] A hash containing two parts: :unit (first part) and :rest (remaining part).
904
+ def history_state_partition
905
+ history_env_value = ENV.fetch(MDE_HISTORY_ENV_NAME, '')
906
+ separator = @delegate_object[:history_document_separator]
907
+
908
+ unit, rest = StringUtil.partition_at_first(history_env_value, separator)
909
+ { unit: unit, rest: rest }
910
+ end
911
+
912
+ # Pops the last entry from the history state, updating the delegate object and environment variable.
913
+ # It also deletes the required temporary file and updates the run state link history.
914
+ def history_state_pop
915
+ state = history_state_partition
916
+ @delegate_object[:filename] = state[:unit]
917
+ ENV[MDE_HISTORY_ENV_NAME] = state[:rest]
918
+ delete_required_temp_file
919
+ @run_state.link_history.pop
920
+ end
921
+
922
+ # Updates the history state by pushing a new entry and managing environment variables.
923
+ # @param mdoc [Object] The Markdown document object.
924
+ # @param data_file [String] The data file to be processed.
925
+ # @param selected [Hash] Hash containing the selected block's name.
926
+ def history_state_push(mdoc, data_file, selected)
927
+ # Construct new history string
928
+ new_history = [@delegate_object[:filename],
929
+ @delegate_object[:history_document_separator],
930
+ ENV.fetch(MDE_HISTORY_ENV_NAME, '')].join
931
+
932
+ # Update delegate object and environment variable
933
+ @delegate_object[:filename] = data_file
934
+ ENV[MDE_HISTORY_ENV_NAME] = new_history
935
+
936
+ # Write required blocks to temp file and update run state
937
+ write_required_blocks_to_temp_file(mdoc, @delegate_object[:block_name])
938
+ @run_state.link_history.push(block_name: selected[:oname],
939
+ filename: data_file)
940
+ end
941
+
942
+ # Indents all lines in a given string with a specified indentation string.
943
+ # @param body [String] A multi-line string to be indented.
944
+ # @param indent [String] The string used for indentation (default is an empty string).
945
+ # @return [String] A single string with each line indented as specified.
946
+ def indent_all_lines(body, indent = nil)
947
+ return body unless indent&.non_empty?
948
+
949
+ body.lines.map { |line| indent + line.chomp }.join("\n")
950
+ end
951
+
952
+ def initialize_fcb_names(fcb)
953
+ fcb.oname = fcb.dname = fcb.title || ''
954
+ end
955
+
956
+ # Initializes variables for regex and other states
957
+ def initial_state
958
+ {
959
+ fenced_start_and_end_regex: Regexp.new(@delegate_object.fetch(
960
+ :fenced_start_and_end_regex, '^(?<indent> *)`{3,}'
961
+ )),
962
+ fenced_start_extended_regex: Regexp.new(@delegate_object.fetch(
963
+ :fenced_start_and_end_regex, '^(?<indent> *)`{3,}'
964
+ )),
965
+ fcb: MarkdownExec::FCB.new,
966
+ in_fenced_block: false,
967
+ headings: []
968
+ }
969
+ end
970
+
971
+ def initialize_and_save_execution_output
972
+ return unless @delegate_object[:save_execution_output]
973
+
974
+ @delegate_object[:logged_stdout_filename] =
975
+ SavedAsset.stdout_name(blockname: @delegate_object[:block_name],
976
+ filename: File.basename(@delegate_object[:filename],
977
+ '.*'),
978
+ prefix: @delegate_object[:logged_stdout_filename_prefix],
979
+ time: Time.now.utc)
980
+
981
+ @logged_stdout_filespec =
982
+ @delegate_object[:logged_stdout_filespec] =
983
+ File.join @delegate_object[:saved_stdout_folder],
984
+ @delegate_object[:logged_stdout_filename]
985
+ @logged_stdout_filespec = @delegate_object[:logged_stdout_filespec]
986
+ write_execution_output_to_file
987
+ end
988
+
989
+ # Iterates through blocks in a file, applying the provided block to each line.
990
+ # The iteration only occurs if the file exists.
991
+ # @yield [Symbol] :filter Yields to obtain selected messages for processing.
992
+ def iter_blocks_from_nested_files(&block)
993
+ return unless check_file_existence(@delegate_object[:filename])
994
+
995
+ state = initial_state
996
+ selected_messages = yield :filter
997
+
998
+ cfile.readlines(@delegate_object[:filename]).each do |nested_line|
999
+ if nested_line
1000
+ update_line_and_block_state(nested_line, state, selected_messages,
1001
+ &block)
1002
+ end
1003
+ end
1004
+ end
1005
+
1006
+ # Loads auto blocks based on delegate object settings and updates if new filename is detected.
1007
+ # Executes a specified block once per filename.
1008
+ # @param all_blocks [Array] Array of all block elements.
1009
+ # @return [Boolean, nil] True if values were modified, nil otherwise.
1010
+ def load_auto_blocks(all_blocks)
1011
+ block_name = @delegate_object[:document_load_opts_block_name]
1012
+ unless block_name.present? && @most_recent_loaded_filename != @delegate_object[:filename]
1013
+ return
1014
+ end
1015
+
1016
+ block = block_find(all_blocks, :oname, block_name)
1017
+ return unless block
1018
+
1019
+ handle_opts_block(block, @delegate_object)
1020
+ @most_recent_loaded_filename = @delegate_object[:filename]
1021
+ true
1022
+ end
1023
+
1024
+ # DebugHelper.d ["HDmm method_name: #{method_name}", "#{first_n_caller_items 1}"]
1025
+ def first_n_caller_items(n)
1026
+ # Get the call stack
1027
+ call_stack = caller
1028
+ base_path = File.realpath('.')
1029
+
1030
+ # Modify the call stack to remove the base path and keep only the first n items
1031
+ call_stack.take(n + 1)[1..].map do |line|
1032
+ " . #{line.sub(/^#{Regexp.escape(base_path)}\//, '')}"
1033
+ end.join("\n")
1034
+ end
1035
+
1036
+ # Checks if a history environment variable is set and returns its value if present.
1037
+ # @return [String, nil] The value of the history environment variable or nil if not present.
1038
+ def history_env_state_exist?
1039
+ ENV.fetch(MDE_HISTORY_ENV_NAME, '').present?
1040
+ end
1041
+
1042
+ # def history_env_state_exist?
1043
+ # history = ENV.fetch(MDE_HISTORY_ENV_NAME, '')
1044
+ # history.present? ? history : nil
1045
+ # end
1046
+
1047
+ # If a method is missing, treat it as a key for the @delegate_object.
1048
+ def method_missing(method_name, *args, &block)
1049
+ if @delegate_object.respond_to?(method_name)
1050
+ @delegate_object.send(method_name, *args, &block)
1051
+ elsif method_name.to_s.end_with?('=') && args.size == 1
1052
+ @delegate_object[method_name.to_s.chop.to_sym] = args.first
1053
+ else
1054
+ @delegate_object[method_name]
1055
+ # super
1056
+ end
1057
+ end
1058
+
1059
+ def mdoc_and_blocks_from_nested_files
1060
+ menu_blocks = blocks_from_nested_files
1061
+ mdoc = MDoc.new(menu_blocks) do |nopts|
1062
+ @delegate_object.merge!(nopts)
1063
+ end
1064
+ [menu_blocks, mdoc]
1065
+ end
1066
+
1067
+ ## Handles the file loading and returns the blocks in the file and MDoc instance
1068
+ #
1069
+ def mdoc_menu_and_blocks_from_nested_files
1070
+ all_blocks, mdoc = mdoc_and_blocks_from_nested_files
1071
+
1072
+ # recreate menu with new options
1073
+ #
1074
+ if load_auto_blocks(all_blocks)
1075
+ all_blocks, mdoc = mdoc_and_blocks_from_nested_files
1076
+ end
1077
+
1078
+ menu_blocks = mdoc.fcbs_per_options(@delegate_object)
1079
+ add_menu_chrome_blocks!(menu_blocks)
1080
+ delete_consecutive_blank_lines!(menu_blocks) if true ### compress empty lines
1081
+ [all_blocks, menu_blocks, mdoc]
1082
+ end
1083
+
1084
+ # Formats and optionally colors a menu option based on delegate object's configuration.
1085
+ # @param option_symbol [Symbol] The symbol key for the menu option in the delegate object.
1086
+ # @return [String] The formatted and possibly colored value of the menu option.
1087
+ def menu_chrome_colored_option(option_symbol = :menu_option_back_name)
1088
+ formatted_option = menu_chrome_formatted_option(option_symbol)
1089
+ return formatted_option unless @delegate_object[:menu_chrome_color]
1090
+
1091
+ string_send_color(formatted_option, :menu_chrome_color)
1092
+ end
1093
+
1094
+ # Formats a menu option based on the delegate object's configuration.
1095
+ # It safely evaluates the value of the option and optionally formats it.
1096
+ # @param option_symbol [Symbol] The symbol key for the menu option in the delegate object.
1097
+ # @return [String] The formatted or original value of the menu option.
1098
+ def menu_chrome_formatted_option(option_symbol = :menu_option_back_name)
1099
+ option_value = safeval(@delegate_object.fetch(option_symbol, ''))
1100
+
1101
+ if @delegate_object[:menu_chrome_format]
1102
+ format(@delegate_object[:menu_chrome_format], option_value)
1103
+ else
1104
+ option_value
1105
+ end
1106
+ end
1107
+
1108
+ def next_block_name_from_command_line_arguments
1109
+ return MenuControl::Repeat unless @delegate_object[:input_cli_rest].present?
1110
+
1111
+ @delegate_object[:block_name] = @delegate_object[:input_cli_rest].pop
1112
+ MenuControl::Fresh
1113
+ end
1114
+
1115
+ def output_execution_result
1116
+ @fout.fout fetch_color(data_sym: :execution_report_preview_head,
1117
+ color_sym: :execution_report_preview_frame_color)
1118
+ [
1119
+ ['Block', @run_state.script_block_name],
1120
+ ['Command', ([MarkdownExec::BIN_NAME, @delegate_object[:filename]] +
1121
+ (@run_state.link_history.map { |item|
1122
+ item[:block_name]
1123
+ }) +
1124
+ [@run_state.script_block_name]).join(' ')],
1125
+ ['Script', @run_state.saved_filespec],
1126
+ ['StdOut', @delegate_object[:logged_stdout_filespec]]
1127
+ ].each do |label, value|
1128
+ next unless value
1129
+
1130
+ output_labeled_value(label, value, DISPLAY_LEVEL_ADMIN)
1131
+ end
1132
+ @fout.fout fetch_color(data_sym: :execution_report_preview_tail,
1133
+ color_sym: :execution_report_preview_frame_color)
1134
+ end
1135
+
1136
+ def output_execution_summary
1137
+ return unless @delegate_object[:output_execution_summary]
1138
+
1139
+ fout_section 'summary', {
1140
+ execute_aborted_at: @run_state.aborted_at,
1141
+ execute_completed_at: @run_state.completed_at,
1142
+ execute_error: @run_state.error,
1143
+ execute_error_message: @run_state.error_message,
1144
+ execute_files: @run_state.files,
1145
+ execute_options: @run_state.options,
1146
+ execute_started_at: @run_state.started_at,
1147
+ script_block_name: @run_state.script_block_name,
1148
+ saved_filespec: @run_state.saved_filespec
1149
+ }
1150
+ end
1151
+
1152
+ def output_labeled_value(label, value, level)
1153
+ @fout.lout format_references_send_color(
1154
+ context: { name: string_send_color(label, :output_execution_label_name_color),
1155
+ value: string_send_color(value.to_s,
1156
+ :output_execution_label_value_color) },
1157
+ format_sym: :output_execution_label_format
1158
+ ), level: level
1159
+ end
1160
+
1161
+ # Prepare the blocks menu by adding labels and other necessary details.
1162
+ #
1163
+ # @param all_blocks [Array<Hash>] The list of blocks from the file.
1164
+ # @param opts [Hash] The options hash.
1165
+ # @return [Array<Hash>] The updated blocks menu.
1166
+ def prepare_blocks_menu(menu_blocks)
1167
+ ### replace_consecutive_blanks(menu_blocks).map do |fcb|
1168
+ menu_blocks.map do |fcb|
1169
+ next if Filter.prepared_not_in_menu?(@delegate_object, fcb,
1170
+ %i[block_name_include_match block_name_wrapper_match])
1171
+
1172
+ fcb.merge!(
1173
+ name: indent_all_lines(fcb.dname, fcb.fetch(:indent, nil)),
1174
+ label: BlockLabel.make(
1175
+ body: fcb[:body],
1176
+ filename: @delegate_object[:filename],
1177
+ headings: fcb.fetch(:headings, []),
1178
+ menu_blocks_with_docname: @delegate_object[:menu_blocks_with_docname],
1179
+ menu_blocks_with_headings: @delegate_object[:menu_blocks_with_headings],
1180
+ text: fcb[:text],
1181
+ title: fcb[:title]
1182
+ )
1183
+ )
1184
+ fcb.to_h
1185
+ end.compact
1186
+ end
1187
+
1188
+ ##
1189
+ # Presents a menu to the user for approving an action and performs additional tasks based on the selection.
1190
+ # The function provides options for approval, rejection, copying data to clipboard, or saving data to a file.
1191
+ #
1192
+ # @param opts [Hash] A hash containing various options for the menu.
1193
+ # @param required_lines [Array<String>] Lines of text or code that are subject to user approval.
1194
+ #
1195
+ # @option opts [String] :prompt_approve_block Prompt text for the approval menu.
1196
+ # @option opts [String] :prompt_yes Text for the 'Yes' choice in the menu.
1197
+ # @option opts [String] :prompt_no Text for the 'No' choice in the menu.
1198
+ # @option opts [String] :prompt_script_to_clipboard Text for the 'Copy to Clipboard' choice in the menu.
1199
+ # @option opts [String] :prompt_save_script Text for the 'Save to File' choice in the menu.
1200
+ #
1201
+ # @return [Boolean] Returns true if the user approves (selects 'Yes'), false otherwise.
1202
+ ##
1203
+ def prompt_for_user_approval(required_lines)
1204
+ # Present a selection menu for user approval.
1205
+ sel = @prompt.select(
1206
+ string_send_color(@delegate_object[:prompt_approve_block],
1207
+ :prompt_color_after_script_execution),
1208
+ filter: true
1209
+ ) do |menu|
1210
+ # sel = @prompt.select(@delegate_object[:prompt_approve_block], filter: true) do |menu|
1211
+ menu.default MenuOptions::YES
1212
+ menu.choice @delegate_object[:prompt_yes], MenuOptions::YES
1213
+ menu.choice @delegate_object[:prompt_no], MenuOptions::NO
1214
+ menu.choice @delegate_object[:prompt_script_to_clipboard],
1215
+ MenuOptions::SCRIPT_TO_CLIPBOARD
1216
+ menu.choice @delegate_object[:prompt_save_script],
1217
+ MenuOptions::SAVE_SCRIPT
1218
+ end
1219
+
1220
+ if sel == MenuOptions::SCRIPT_TO_CLIPBOARD
1221
+ copy_to_clipboard(required_lines)
1222
+ elsif sel == MenuOptions::SAVE_SCRIPT
1223
+ save_to_file(required_lines)
1224
+ end
1225
+
1226
+ sel == MenuOptions::YES
1227
+ rescue TTY::Reader::InputInterrupt
1228
+ exit 1
1229
+ end
1230
+
1231
+ def prompt_select_continue
1232
+ sel = @prompt.select(
1233
+ string_send_color(@delegate_object[:prompt_after_script_execution],
1234
+ :prompt_color_after_script_execution),
1235
+ filter: true,
1236
+ quiet: true
1237
+ ) do |menu|
1238
+ menu.choice @delegate_object[:prompt_yes]
1239
+ menu.choice @delegate_object[:prompt_exit]
1240
+ end
1241
+ sel == @delegate_object[:prompt_exit] ? MenuState::EXIT : MenuState::CONTINUE
1242
+ rescue TTY::Reader::InputInterrupt
1243
+ exit 1
1244
+ end
1245
+
1246
+ def save_to_file(required_lines)
1247
+ write_command_file(required_lines)
1248
+ @fout.fout "File saved: #{@run_state.saved_filespec}"
1249
+ end
1250
+
1251
+ # public
1252
+
1253
+ # Reads required code blocks from a temporary file specified by an environment variable.
1254
+ # @return [Array<String>] Lines read from the temporary file, or an empty array if file is not found or path is empty.
1255
+ def read_required_blocks_from_temp_file
1256
+ temp_blocks_file_path = ENV.fetch('MDE_LINK_REQUIRED_FILE', nil)
1257
+ return [] if temp_blocks_file_path.to_s.empty?
1258
+
1259
+ if File.exist?(temp_blocks_file_path)
1260
+ File.readlines(
1261
+ temp_blocks_file_path, chomp: true
1262
+ )
1263
+ else
1264
+ []
1265
+ end
1266
+ end
1267
+
1268
+ # Evaluates the given string as Ruby code and rescues any StandardErrors.
1269
+ # If an error occurs, it calls the error_handler method with 'safeval'.
1270
+ # @param str [String] The string to be evaluated.
1271
+ # @return [Object] The result of evaluating the string.
1272
+ def safeval(str)
1273
+ eval(str)
1274
+ rescue StandardError
1275
+ error_handler('safeval')
1276
+ end
1277
+
1278
+ # def safeval(str)
1279
+ # eval(str)
1280
+ # rescue StandardError
1281
+ # error_handler('safeval')
1282
+ # end
1283
+
1284
+ # Select and execute a code block from a Markdown document.
1285
+ #
1286
+ # This method allows the user to interactively select a code block from a
1287
+ # Markdown document, obtain approval, and execute the chosen block of code.
1288
+ #
1289
+ # @return [Nil] Returns nil if no code block is selected or an error occurs.
1290
+ def select_approve_and_execute_block(_execute: true)
1291
+ @menu_base_options = @delegate_object
1292
+ repeat_menu = @menu_base_options[:block_name].present? ? MenuControl::Fresh : MenuControl::Repeat
1293
+ load_file_next_block = LoadFileNextBlock.new(LoadFile::Reuse)
1294
+ default = nil
1295
+
1296
+ @menu_state_filename = @menu_base_options[:filename]
1297
+ @menu_state_block_name = @menu_base_options[:block_name]
1298
+
1299
+ loop do
1300
+ loop do
1301
+ @delegate_object = @menu_base_options.dup
1302
+ @menu_base_options[:filename] = @menu_state_filename
1303
+ @menu_base_options[:block_name] = @menu_state_block_name
1304
+ @menu_state_filename = nil
1305
+ @menu_state_block_name = nil
1306
+ @menu_user_clicked_back_link = false
1307
+
1308
+ blocks_in_file, menu_blocks, mdoc = mdoc_menu_and_blocks_from_nested_files
1309
+ if @delegate_object[:dump_blocks_in_file]
1310
+ warn format_and_highlight_dependencies(
1311
+ compact_and_index_hash(blocks_in_file),
1312
+ label: 'blocks_in_file'
1313
+ )
1314
+ end
1315
+ if @delegate_object[:dump_menu_blocks]
1316
+ warn format_and_highlight_dependencies(
1317
+ compact_and_index_hash(menu_blocks),
1318
+ label: 'menu_blocks'
1319
+ )
1320
+ end
1321
+ block_state = command_or_user_selected_block(blocks_in_file,
1322
+ menu_blocks, default)
1323
+ return if block_state.state == MenuState::EXIT
1324
+
1325
+ if block_state.block.nil?
1326
+ warn_format('select_approve_and_execute_block', "Block not found -- #{@delegate_object[:block_name]}",
1327
+ { abort: true })
1328
+ # error_handler("Block not found -- #{opts[:block_name]}", { abort: true })
1329
+ end
1330
+
1331
+ if @delegate_object[:dump_selected_block]
1332
+ warn block_state.block.to_yaml.sub(/^(?:---\n)?/, "Block:\n")
1333
+ end
1334
+
1335
+ load_file_next_block = approve_and_execute_block(block_state.block,
1336
+ mdoc)
1337
+ default = load_file_next_block.load_file == LoadFile::Load ? nil : @delegate_object[:block_name]
1338
+ @menu_base_options[:block_name] =
1339
+ @delegate_object[:block_name] = load_file_next_block.next_block
1340
+ @menu_base_options[:filename] = @delegate_object[:filename]
1341
+
1342
+ # user prompt to exit if the menu will be displayed again
1343
+ #
1344
+ if repeat_menu == MenuControl::Repeat &&
1345
+ block_state.block[:shell] == BlockType::BASH &&
1346
+ @delegate_object[:pause_after_script_execution] &&
1347
+ prompt_select_continue == MenuState::EXIT
1348
+ return
1349
+ end
1350
+
1351
+ # exit current document/menu if loading next document or single block_name was specified
1352
+ #
1353
+ if block_state.state == MenuState::CONTINUE && load_file_next_block.load_file == LoadFile::Load
1354
+ break
1355
+ end
1356
+ break if repeat_menu == MenuControl::Fresh
1357
+ end
1358
+ break if load_file_next_block.load_file == LoadFile::Reuse
1359
+
1360
+ repeat_menu = next_block_name_from_command_line_arguments
1361
+ @menu_state_filename = @menu_base_options[:filename]
1362
+ @menu_state_block_name = @menu_base_options[:block_name]
1363
+ end
1364
+ rescue StandardError
1365
+ error_handler('select_approve_and_execute_block',
1366
+ { abort: true })
1367
+ end
1368
+
1369
+ # Presents a TTY prompt to select an option or exit, returns metadata including option and selected
1370
+ def select_option_with_metadata(prompt_text, names, opts = {})
1371
+ selection = @prompt.select(prompt_text,
1372
+ names,
1373
+ opts.merge(filter: true))
1374
+ item = if names.first.instance_of?(String)
1375
+ { dname: selection }
1376
+ else
1377
+ names.find { |item| item[:dname] == selection }
1378
+ end
1379
+
1380
+ item.merge(
1381
+ if selection == menu_chrome_colored_option(:menu_option_back_name)
1382
+ { option: selection, curr: @hs_curr, rest: @hs_rest,
1383
+ shell: BlockType::LINK }
1384
+ elsif selection == menu_chrome_colored_option(:menu_option_exit_name)
1385
+ { option: selection }
1386
+ else
1387
+ { selected: selection }
1388
+ end
1389
+ )
1390
+ rescue TTY::Reader::InputInterrupt
1391
+ exit 1
1392
+ rescue StandardError
1393
+ error_handler('select_option_with_metadata')
1394
+ end
1395
+
1396
+ # Initializes a new fenced code block (FCB) object based on the provided line and heading information.
1397
+ # @param line [String] The line initiating the fenced block.
1398
+ # @param headings [Array<String>] Current headings hierarchy.
1399
+ # @param fenced_start_extended_regex [Regexp] Regular expression to identify fenced block start.
1400
+ # @return [MarkdownExec::FCB] A new FCB instance with the parsed attributes.
1401
+ def start_fenced_block(line, headings, fenced_start_extended_regex)
1402
+ fcb_title_groups = line.match(fenced_start_extended_regex).named_captures.sym_keys
1403
+ rest = fcb_title_groups.fetch(:rest, '')
1404
+ reqs, wraps =
1405
+ ArrayUtil.partition_by_predicate(rest.scan(/\+[^\s]+/).map do |req|
1406
+ req[1..-1]
1407
+ end) do |name|
1408
+ !name.match(Regexp.new(@delegate_object[:block_name_wrapper_match]))
1409
+ end
1410
+
1411
+ MarkdownExec::FCB.new(
1412
+ body: [],
1413
+ call: rest.match(Regexp.new(@delegate_object[:block_calls_scan]))&.to_a&.first,
1414
+ dname: fcb_title_groups.fetch(:name, ''),
1415
+ headings: headings,
1416
+ indent: fcb_title_groups.fetch(:indent, ''),
1417
+ oname: fcb_title_groups.fetch(:name, ''),
1418
+ reqs: reqs,
1419
+ shell: fcb_title_groups.fetch(:shell, ''),
1420
+ stdin: if (tn = rest.match(/<(?<type>\$)?(?<name>[A-Za-z_-]\S+)/))
1421
+ tn.named_captures.sym_keys
1422
+ end,
1423
+ stdout: if (tn = rest.match(/>(?<type>\$)?(?<name>[A-Za-z_\-.\w]+)/))
1424
+ tn.named_captures.sym_keys
1425
+ end,
1426
+ title: fcb_title_groups.fetch(:name, ''),
1427
+ wraps: wraps
1428
+ )
1429
+ end
1430
+
1431
+ # Applies a color method to a string based on the provided color symbol.
1432
+ # The color method is fetched from @delegate_object and applied to the string.
1433
+ # @param string [String] The string to which the color will be applied.
1434
+ # @param color_sym [Symbol] The symbol representing the color method.
1435
+ # @param default [String] Default color method to use if color_sym is not found in @delegate_object.
1436
+ # @return [String] The string with the applied color method.
1437
+ def string_send_color(string, color_sym, default: 'plain')
1438
+ color_method = @delegate_object.fetch(color_sym, default).to_sym
1439
+ string.to_s.send(color_method)
1440
+ end
1441
+
1442
+ # Creates a TTY prompt with custom settings. Specifically, it disables the default 'cross' symbol and
1443
+ # defines a lambda function to handle interrupts.
1444
+ # @return [TTY::Prompt] A new TTY::Prompt instance with specified configurations.
1445
+ def tty_prompt_without_disabled_symbol
1446
+ TTY::Prompt.new(
1447
+ interrupt: lambda {
1448
+ puts
1449
+ raise TTY::Reader::InputInterrupt
1450
+ },
1451
+ symbols: { cross: ' ' }
1452
+ )
1453
+ end
1454
+
1455
+ # Updates the hierarchy of document headings based on the given line.
1456
+ # Utilizes regular expressions to identify heading levels.
1457
+ # @param line [String] The line of text to check for headings.
1458
+ # @param headings [Array<String>] Current headings hierarchy.
1459
+ # @return [Array<String>] Updated headings hierarchy.
1460
+ def update_document_headings(line, headings)
1461
+ heading3_match = Regexp.new(@delegate_object[:heading3_match])
1462
+ heading2_match = Regexp.new(@delegate_object[:heading2_match])
1463
+ heading1_match = Regexp.new(@delegate_object[:heading1_match])
1464
+
1465
+ case line
1466
+ when heading3_match
1467
+ [headings[0], headings[1], $~[:name]]
1468
+ when heading2_match
1469
+ [headings[0], $~[:name]]
1470
+ when heading1_match
1471
+ [$~[:name]]
1472
+ else
1473
+ headings
1474
+ end
1475
+ end
1476
+
1477
+ ##
1478
+ # Processes an individual line within a loop, updating headings and handling fenced code blocks.
1479
+ # This function is designed to be called within a loop that iterates through each line of a document.
1480
+ #
1481
+ # @param line [String] The current line being processed.
1482
+ # @param state [Hash] The current state of the parser, including flags and data related to the processing.
1483
+ # @param opts [Hash] A hash containing various options for line and block processing.
1484
+ # @param selected_messages [Array<String>] Accumulator for lines or messages that are subject to further processing.
1485
+ # @param block [Proc] An optional block for further processing or transformation of lines.
1486
+ #
1487
+ # @option state [Array<String>] :headings Current headings to be updated based on the line.
1488
+ # @option state [Regexp] :fenced_start_and_end_regex Regular expression to match the start and end of a fenced block.
1489
+ # @option state [Boolean] :in_fenced_block Flag indicating whether the current line is inside a fenced block.
1490
+ # @option state [Object] :fcb An object representing the current fenced code block being processed.
1491
+ #
1492
+ # @option opts [Boolean] :menu_blocks_with_headings Flag indicating whether to update headings while processing.
1493
+ #
1494
+ # @return [Void] The function modifies the `state` and `selected_messages` arguments in place.
1495
+ ##
1496
+ def update_line_and_block_state(nested_line, state, selected_messages,
1497
+ &block)
1498
+ line = nested_line.to_s
1499
+ if @delegate_object[:menu_blocks_with_headings]
1500
+ state[:headings] = update_document_headings(line, state[:headings])
1501
+ end
1502
+
1503
+ if line.match(@delegate_object[:fenced_start_and_end_regex])
1504
+ if state[:in_fenced_block]
1505
+ ## end of code block
1506
+ #
1507
+ update_menu_attrib_yield_selected(state[:fcb], selected_messages,
1508
+ &block)
1509
+ state[:in_fenced_block] = false
1510
+ else
1511
+ ## start of code block
1512
+ #
1513
+ state[:fcb] =
1514
+ start_fenced_block(line, state[:headings],
1515
+ @delegate_object[:fenced_start_extended_regex])
1516
+ state[:fcb][:depth] = nested_line[:depth]
1517
+ state[:in_fenced_block] = true
1518
+ end
1519
+ elsif state[:in_fenced_block] && state[:fcb].body
1520
+ ## add line to fenced code block
1521
+ # remove fcb indent if possible
1522
+ #
1523
+ # if nested_line[:depth].zero? || opts[:menu_include_imported_blocks]
1524
+ # if add_import_block?(nested_line)
1525
+ state[:fcb].body += [
1526
+ line.chomp.sub(/^#{state[:fcb].indent}/, '')
1527
+ ]
1528
+ # end
1529
+ elsif nested_line[:depth].zero? || @delegate_object[:menu_include_imported_notes]
1530
+ # add line if it is depth 0 or option allows it
1531
+ #
1532
+ yield_line_if_selected(line, selected_messages, &block)
1533
+ end
1534
+ end
1535
+
1536
+ # Updates the attributes of the given fcb object and conditionally yields to a block.
1537
+ # It initializes fcb names and sets the default block title from fcb's body.
1538
+ # If the fcb has a body and meets certain conditions, it yields to the given block.
1539
+ #
1540
+ # @param fcb [Object] The fcb object whose attributes are to be updated.
1541
+ # @param selected_messages [Array<Symbol>] A list of message types to determine if yielding is applicable.
1542
+ # @param block [Block] An optional block to yield to if conditions are met.
1543
+ def update_menu_attrib_yield_selected(fcb, selected_messages, &block)
1544
+ initialize_fcb_names(fcb)
1545
+ return unless fcb.body
1546
+
1547
+ default_block_title_from_body(fcb)
1548
+ yield_to_block_if_applicable(fcb, selected_messages, &block)
1549
+ end
1550
+
1551
+ def wait_for_user_selected_block(all_blocks, menu_blocks, default)
1552
+ block_state = wait_for_user_selection(all_blocks, menu_blocks, default)
1553
+ handle_block_state(block_state)
1554
+
1555
+ block_state
1556
+ rescue StandardError
1557
+ error_handler('wait_for_user_selected_block')
1558
+ end
1559
+
1560
+ def wait_for_user_selection(_all_blocks, menu_blocks, default)
1561
+ prompt_title = string_send_color(
1562
+ @delegate_object[:prompt_select_block].to_s, :prompt_color_after_script_execution
1563
+ )
1564
+
1565
+ block_menu = prepare_blocks_menu(menu_blocks)
1566
+ return SelectedBlockMenuState.new(nil, MenuState::EXIT) if block_menu.empty?
1567
+
1568
+ # default value may not match if color is different from originating menu (opts changed while processing)
1569
+ selection_opts = if default && menu_blocks.map(&:dname).include?(default)
1570
+ @delegate_object.merge(default: default)
1571
+ else
1572
+ @delegate_object
1573
+ end
1574
+
1575
+ selection_opts.merge!(per_page: @delegate_object[:select_page_height])
1576
+
1577
+ selected_option = select_option_with_metadata(prompt_title, block_menu,
1578
+ selection_opts)
1579
+ determine_block_state(selected_option)
1580
+ end
1581
+
1582
+ # Handles the core logic for generating the command file's metadata and content.
1583
+ def write_command_file(required_lines)
1584
+ return unless @delegate_object[:save_executed_script]
1585
+
1586
+ time_now = Time.now.utc
1587
+ @run_state.saved_script_filename =
1588
+ SavedAsset.script_name(blockname: @delegate_object[:block_name],
1589
+ filename: @delegate_object[:filename],
1590
+ prefix: @delegate_object[:saved_script_filename_prefix],
1591
+ time: time_now)
1592
+
1593
+ @run_state.saved_filespec =
1594
+ File.join(@delegate_object[:saved_script_folder],
1595
+ @run_state.saved_script_filename)
1596
+
1597
+ shebang = if @delegate_object[:shebang]&.present?
1598
+ "#{@delegate_object[:shebang]} #{@delegate_object[:shell]}\n"
1599
+ else
1600
+ ''
1601
+ end
1602
+
1603
+ content = shebang +
1604
+ "# file_name: #{@delegate_object[:filename]}\n" \
1605
+ "# block_name: #{@delegate_object[:block_name]}\n" \
1606
+ "# time: #{time_now}\n" \
1607
+ "#{required_lines.flatten.join("\n")}\n"
1608
+
1609
+ create_and_write_file_with_permissions(
1610
+ @run_state.saved_filespec,
1611
+ content,
1612
+ @delegate_object[:saved_script_chmod]
1613
+ )
1614
+ rescue StandardError
1615
+ error_handler('write_command_file')
1616
+ end
1617
+
1618
+ def write_execution_output_to_file
1619
+ FileUtils.mkdir_p File.dirname(@delegate_object[:logged_stdout_filespec])
1620
+
1621
+ File.write(
1622
+ @delegate_object[:logged_stdout_filespec],
1623
+ ["-STDOUT-\n",
1624
+ format_execution_streams(ExecutionStreams::StdOut),
1625
+ "-STDERR-\n",
1626
+ format_execution_streams(ExecutionStreams::StdErr),
1627
+ "-STDIN-\n",
1628
+ format_execution_streams(ExecutionStreams::StdIn),
1629
+ "\n"].join
1630
+ )
1631
+ end
1632
+
1633
+ # Writes required code blocks to a temporary file and sets an environment variable with its path.
1634
+ #
1635
+ # @param mdoc [Object] The Markdown document object.
1636
+ # @param block_name [String] The name of the block to collect code for.
1637
+ def write_required_blocks_to_temp_file(mdoc, block_name)
1638
+ c1 = if mdoc
1639
+ mdoc.collect_recursively_required_code(
1640
+ block_name,
1641
+ label_format_above: @delegate_object[:shell_code_label_format_above],
1642
+ label_format_below: @delegate_object[:shell_code_label_format_below]
1643
+ )[:code]
1644
+ else
1645
+ []
1646
+ end
1647
+
1648
+ code_blocks = (read_required_blocks_from_temp_file +
1649
+ c1).join("\n")
1650
+
1651
+ create_temp_file_with_code(code_blocks)
1652
+ end
1653
+
1654
+ # Yields a line as a new block if the selected message type includes :line.
1655
+ # @param [String] line The line to be processed.
1656
+ # @param [Array<Symbol>] selected_messages A list of message types to check.
1657
+ # @param [Proc] block The block to be called with the line data.
1658
+ def yield_line_if_selected(line, selected_messages, &block)
1659
+ return unless block && selected_messages.include?(:line)
1660
+
1661
+ block.call(:line, FCB.new(body: [line]))
1662
+ end
1663
+
1664
+ # Yields to the provided block with specified parameters if certain conditions are met.
1665
+ # The method checks if a block is given, if the selected_messages include :blocks,
1666
+ # and if the fcb_select? method from MarkdownExec::Filter returns true for the given fcb.
1667
+ #
1668
+ # @param fcb [Object] The object to be evaluated and potentially passed to the block.
1669
+ # @param selected_messages [Array<Symbol>] A collection of message types, one of which must be :blocks.
1670
+ # @param block [Block] A block to be called if conditions are met.
1671
+ def yield_to_block_if_applicable(fcb, selected_messages, &block)
1672
+ if block_given? && selected_messages.include?(:blocks) &&
1673
+ MarkdownExec::Filter.fcb_select?(@delegate_object, fcb)
1674
+ block.call :blocks, fcb
1675
+ end
1676
+ end
1677
+ end
1678
+ end
1679
+
1680
+ if $PROGRAM_NAME == __FILE__
1681
+ require 'bundler/setup'
1682
+ Bundler.require(:default)
1683
+
1684
+ require 'minitest/autorun'
1685
+ require 'mocha/minitest'
1686
+
1687
+ module MarkdownExec
1688
+ class TestHashDelegator < Minitest::Test
1689
+ def setup
1690
+ @hd = HashDelegator.new
1691
+ @mdoc = mock('MarkdownDocument')
1692
+ end
1693
+
1694
+ def test_calling_execute_approved_block_calls_command_execute_with_argument_args_value
1695
+ pigeon = 'E'
1696
+ obj = {
1697
+ output_execution_label_format: '',
1698
+ output_execution_label_name_color: 'plain',
1699
+ output_execution_label_value_color: 'plain'
1700
+ }
1701
+
1702
+ c = MarkdownExec::HashDelegator.new(obj)
1703
+ c.pass_args = pigeon
1704
+
1705
+ # Expect that method opts_command_execute is called with argument args having value pigeon
1706
+ c.expects(:command_execute).with(
1707
+ '',
1708
+ args: pigeon
1709
+ )
1710
+
1711
+ # Call method opts_execute_approved_block
1712
+ c.execute_approved_block([], MarkdownExec::FCB.new)
1713
+ end
1714
+
1715
+ # Test case for empty body
1716
+ def test_handle_link_block_with_empty_body
1717
+ assert_equal LoadFileNextBlock.new(LoadFile::Reuse, ''),
1718
+ @hd.handle_link_block([], nil, false)
1719
+ end
1720
+
1721
+ # Test case for non-empty body without 'file' key
1722
+ def test_handle_link_block_without_file_key
1723
+ body = ["vars:\n KEY: VALUE"]
1724
+ assert_equal LoadFileNextBlock.new(LoadFile::Reuse, ''),
1725
+ @hd.handle_link_block(body, nil, false)
1726
+ end
1727
+
1728
+ # Test case for non-empty body with 'file' key
1729
+ def test_handle_link_block_with_file_key
1730
+ body = ["file: sample_file\nblock: sample_block\nvars:\n KEY: VALUE"]
1731
+ expected_result = LoadFileNextBlock.new(LoadFile::Load,
1732
+ 'sample_block')
1733
+ # mdoc = MDoc.new()
1734
+ assert_equal expected_result,
1735
+ @hd.handle_link_block(body, nil, FCB.new)
1736
+ end
1737
+
1738
+ def test_history_env_state_exist_with_value
1739
+ ENV[MDE_HISTORY_ENV_NAME] = 'history_value'
1740
+ assert @hd.history_env_state_exist?
1741
+ end
1742
+
1743
+ def test_history_env_state_exist_without_value
1744
+ ENV[MDE_HISTORY_ENV_NAME] = ''
1745
+ refute @hd.history_env_state_exist?
1746
+ end
1747
+
1748
+ def test_history_env_state_exist_not_set
1749
+ ENV.delete(MDE_HISTORY_ENV_NAME)
1750
+ refute @hd.history_env_state_exist?
1751
+ end
1752
+
1753
+ def test_indent_all_lines_with_indent
1754
+ body = "Line 1\nLine 2"
1755
+ indent = ' ' # Two spaces
1756
+ expected_result = " Line 1\n Line 2"
1757
+ assert_equal expected_result, @hd.indent_all_lines(body, indent)
1758
+ end
1759
+
1760
+ def test_indent_all_lines_without_indent
1761
+ body = "Line 1\nLine 2"
1762
+ indent = nil
1763
+
1764
+ assert_equal body, @hd.indent_all_lines(body, indent)
1765
+ end
1766
+
1767
+ def test_indent_all_lines_with_empty_indent
1768
+ body = "Line 1\nLine 2"
1769
+ indent = ''
1770
+
1771
+ assert_equal body, @hd.indent_all_lines(body, indent)
1772
+ end
1773
+
1774
+ def test_read_required_blocks_from_temp_file
1775
+ Tempfile.create do |file|
1776
+ file.write("Line 1\nLine 2")
1777
+ file.rewind
1778
+ ENV['MDE_LINK_REQUIRED_FILE'] = file.path
1779
+
1780
+ result = @hd.read_required_blocks_from_temp_file
1781
+ assert_equal ['Line 1', 'Line 2'], result
1782
+ end
1783
+ end
1784
+
1785
+ def test_read_required_blocks_from_temp_file_no_file
1786
+ ENV['MDE_LINK_REQUIRED_FILE'] = nil
1787
+ assert_empty @hd.read_required_blocks_from_temp_file
1788
+ end
1789
+
1790
+ def test_safeval_successful_evaluation
1791
+ assert_equal 4, @hd.safeval('2 + 2')
1792
+ end
1793
+
1794
+ def test_safeval_rescue_from_error
1795
+ @hd.stubs(:error_handler).with('safeval')
1796
+ assert_nil @hd.safeval('invalid code')
1797
+ end
1798
+
1799
+ def test_set_fcb_title
1800
+ # sample input and output data for testing default_block_title_from_body method
1801
+ input_output_data = [
1802
+ {
1803
+ input: MarkdownExec::FCB.new(title: nil,
1804
+ body: ["puts 'Hello, world!'"]),
1805
+ output: "puts 'Hello, world!'"
1806
+ },
1807
+ {
1808
+ input: MarkdownExec::FCB.new(title: '',
1809
+ body: ['def add(x, y)',
1810
+ ' x + y', 'end']),
1811
+ output: "def add(x, y)\n x + y\n end\n"
1812
+ },
1813
+ {
1814
+ input: MarkdownExec::FCB.new(title: 'foo', body: %w[bar baz]),
1815
+ output: 'foo' # expect the title to remain unchanged
1816
+ }
1817
+ ]
1818
+
1819
+ # hd = HashDelegator.new
1820
+
1821
+ # iterate over the input and output data and
1822
+ # assert that the method sets the title as expected
1823
+ input_output_data.each do |data|
1824
+ input = data[:input]
1825
+ output = data[:output]
1826
+ @hd.default_block_title_from_body(input)
1827
+ assert_equal output, input.title
1828
+ end
1829
+ end
1830
+
1831
+ class TestHashDelegatorAppendDivider < Minitest::Test
1832
+ def setup
1833
+ @hd = HashDelegator.new
1834
+ @hd.instance_variable_set(:@delegate_object, {
1835
+ menu_divider_format: 'Format',
1836
+ menu_initial_divider: 'Initial Divider',
1837
+ menu_final_divider: 'Final Divider',
1838
+ menu_divider_color: :color
1839
+ })
1840
+ @hd.stubs(:string_send_color).returns('Formatted Divider')
1841
+ @hd.stubs(:safeval).returns('Safe Value')
1842
+ end
1843
+
1844
+ def test_append_divider_initial
1845
+ menu_blocks = []
1846
+ @hd.append_divider(menu_blocks, :initial)
1847
+
1848
+ assert_equal 1, menu_blocks.size
1849
+ assert_equal 'Formatted Divider', menu_blocks.first.dname
1850
+ end
1851
+
1852
+ def test_append_divider_final
1853
+ menu_blocks = []
1854
+ @hd.append_divider(menu_blocks, :final)
1855
+
1856
+ assert_equal 1, menu_blocks.size
1857
+ assert_equal 'Formatted Divider', menu_blocks.last.dname
1858
+ end
1859
+
1860
+ def test_append_divider_without_format
1861
+ @hd.instance_variable_set(:@delegate_object, {})
1862
+ menu_blocks = []
1863
+ @hd.append_divider(menu_blocks, :initial)
1864
+
1865
+ assert_empty menu_blocks
1866
+ end
1867
+ end # class TestHashDelegator
1868
+
1869
+ class TestHashDelegatorBlockFind < Minitest::Test
1870
+ def setup
1871
+ @hd = HashDelegator.new
1872
+ end
1873
+
1874
+ def test_block_find_with_match
1875
+ blocks = [{ key: 'value1' }, { key: 'value2' }]
1876
+ result = @hd.block_find(blocks, :key, 'value1')
1877
+ assert_equal({ key: 'value1' }, result)
1878
+ end
1879
+
1880
+ def test_block_find_without_match
1881
+ blocks = [{ key: 'value1' }, { key: 'value2' }]
1882
+ result = @hd.block_find(blocks, :key, 'value3')
1883
+ assert_nil result
1884
+ end
1885
+
1886
+ def test_block_find_with_default
1887
+ blocks = [{ key: 'value1' }, { key: 'value2' }]
1888
+ result = @hd.block_find(blocks, :key, 'value3', 'default')
1889
+ assert_equal 'default', result
1890
+ end
1891
+ end # class TestHashDelegator
1892
+
1893
+ class TestHashDelegatorBlocksFromNestedFiles < Minitest::Test
1894
+ def setup
1895
+ @hd = HashDelegator.new
1896
+ @hd.stubs(:iter_blocks_from_nested_files).yields(:blocks, FCB.new)
1897
+ @hd.stubs(:get_block_summary).returns(FCB.new)
1898
+ @hd.stubs(:create_and_add_chrome_blocks)
1899
+ @hd.instance_variable_set(:@delegate_object, {})
1900
+ @hd.stubs(:error_handler)
1901
+ end
1902
+
1903
+ def test_blocks_from_nested_files
1904
+ result = @hd.blocks_from_nested_files
1905
+
1906
+ assert_kind_of Array, result
1907
+ assert_kind_of FCB, result.first
1908
+ end
1909
+
1910
+ def test_blocks_from_nested_files_with_no_chrome
1911
+ @hd.instance_variable_set(:@delegate_object, { no_chrome: true })
1912
+ @hd.expects(:create_and_add_chrome_blocks).never
1913
+
1914
+ result = @hd.blocks_from_nested_files
1915
+
1916
+ assert_kind_of Array, result
1917
+ end
1918
+ end # class TestHashDelegator
1919
+
1920
+ class TestHashDelegatorCollectRequiredCodeLines < Minitest::Test
1921
+ def setup
1922
+ @hd = HashDelegator.new
1923
+ @hd.instance_variable_set(:@delegate_object, {})
1924
+ @mdoc = mock('YourMDocClass')
1925
+ @selected = { shell: BlockType::VARS, body: ['key: value'] }
1926
+ @hd.stubs(:read_required_blocks_from_temp_file).returns([])
1927
+ @hd.stubs(:string_send_color)
1928
+ @hd.stubs(:print)
1929
+ end
1930
+
1931
+ def test_collect_required_code_lines_with_vars
1932
+ YAML.stubs(:load).returns({ 'key' => 'value' })
1933
+ @mdoc.stubs(:collect_recursively_required_code).returns({ code: ['code line'] })
1934
+ ENV.stubs(:[]=)
1935
+
1936
+ result = @hd.collect_required_code_lines(@mdoc, @selected)
1937
+
1938
+ assert_equal ['code line'], result
1939
+ end
1940
+ end # class TestHashDelegator
1941
+
1942
+ class TestHashDelegatorCommandOrUserSelectedBlock < Minitest::Test
1943
+ def setup
1944
+ @hd = HashDelegator.new
1945
+ @hd.instance_variable_set(:@delegate_object, {})
1946
+ @hd.stubs(:error_handler)
1947
+ @hd.stubs(:wait_for_user_selected_block)
1948
+ end
1949
+
1950
+ def test_command_selected_block
1951
+ all_blocks = [{ oname: 'block1' }, { oname: 'block2' }]
1952
+ @hd.instance_variable_set(:@delegate_object,
1953
+ { block_name: 'block1' })
1954
+
1955
+ result = @hd.command_or_user_selected_block(all_blocks, [], nil)
1956
+
1957
+ assert_equal all_blocks.first, result.block
1958
+ assert_nil result.state
1959
+ end
1960
+
1961
+ def test_user_selected_block
1962
+ block_state = SelectedBlockMenuState.new({ oname: 'block2' },
1963
+ :some_state)
1964
+ @hd.stubs(:wait_for_user_selected_block).returns(block_state)
1965
+
1966
+ result = @hd.command_or_user_selected_block([], [], nil)
1967
+
1968
+ assert_equal block_state.block, result.block
1969
+ assert_equal :some_state, result.state
1970
+ end
1971
+ end # class TestHashDelegator
1972
+
1973
+ class TestHashDelegatorCountBlockInFilename < Minitest::Test
1974
+ def setup
1975
+ @hd = HashDelegator.new
1976
+ @hd.instance_variable_set(:@delegate_object,
1977
+ { fenced_start_and_end_regex: '^```',
1978
+ filename: '/path/to/file' })
1979
+ @hd.stubs(:cfile).returns(mock('cfile'))
1980
+ end
1981
+
1982
+ def test_count_blocks_in_filename
1983
+ file_content = ["```ruby\n", "puts 'Hello'\n", "```\n",
1984
+ "```python\n", "print('Hello')\n", "```\n"]
1985
+ @hd.cfile.stubs(:readlines).with('/path/to/file').returns(file_content)
1986
+
1987
+ count = @hd.count_blocks_in_filename
1988
+
1989
+ assert_equal 2, count
1990
+ end
1991
+
1992
+ def test_count_blocks_in_filename_with_no_matches
1993
+ file_content = ["puts 'Hello'\n", "print('Hello')\n"]
1994
+ @hd.cfile.stubs(:readlines).with('/path/to/file').returns(file_content)
1995
+
1996
+ count = @hd.count_blocks_in_filename
1997
+
1998
+ assert_equal 0, count
1999
+ end
2000
+ end # class TestHashDelegator
2001
+
2002
+ class TestHashDelegatorCreateAndWriteFile < Minitest::Test
2003
+ def setup
2004
+ @hd = HashDelegator.new
2005
+ @hd.stubs(:error_handler)
2006
+ FileUtils.stubs(:mkdir_p)
2007
+ File.stubs(:write)
2008
+ File.stubs(:chmod)
2009
+ end
2010
+
2011
+ def test_create_and_write_file_with_permissions
2012
+ file_path = '/path/to/file'
2013
+ content = 'sample content'
2014
+ chmod_value = 0o644
2015
+
2016
+ FileUtils.expects(:mkdir_p).with('/path/to').once
2017
+ File.expects(:write).with(file_path, content).once
2018
+ File.expects(:chmod).with(chmod_value, file_path).once
2019
+
2020
+ @hd.create_and_write_file_with_permissions(file_path, content,
2021
+ chmod_value)
2022
+
2023
+ assert true # Placeholder for actual test assertions
2024
+ end
2025
+
2026
+ def test_create_and_write_file_without_chmod
2027
+ file_path = '/path/to/file'
2028
+ content = 'sample content'
2029
+ chmod_value = 0
2030
+
2031
+ FileUtils.expects(:mkdir_p).with('/path/to').once
2032
+ File.expects(:write).with(file_path, content).once
2033
+ File.expects(:chmod).never
2034
+
2035
+ @hd.create_and_write_file_with_permissions(file_path, content,
2036
+ chmod_value)
2037
+
2038
+ assert true # Placeholder for actual test assertions
2039
+ end
2040
+ end # class TestHashDelegator
2041
+
2042
+ class TestHashDelegatorCreateTempFile < Minitest::Test
2043
+ def setup
2044
+ @hd = HashDelegator.new
2045
+ @temp_file_path = '/tmp/tempfile'
2046
+ end
2047
+
2048
+ def test_create_temp_file_with_code
2049
+ Dir::Tmpname.stubs(:create).returns(@temp_file_path)
2050
+ File.stubs(:write).with(@temp_file_path, 'code_blocks')
2051
+ # ENV.expects(:[]=).with('MDE_LINK_REQUIRED_FILE', @temp_file_path)
2052
+
2053
+ @hd.create_temp_file_with_code('code_blocks')
2054
+
2055
+ assert true # Placeholder for actual test assertions
2056
+ end
2057
+ end # class TestHashDelegator
2058
+
2059
+ class TestHashDelegatorDeleteRequiredTempFile < Minitest::Test
2060
+ def setup
2061
+ @hd = HashDelegator.new
2062
+ @hd.stubs(:error_handler)
2063
+ @hd.stubs(:clear_required_file)
2064
+ FileUtils.stubs(:rm_f)
2065
+ end
2066
+
2067
+ def test_delete_required_temp_file_with_existing_file
2068
+ ENV.stubs(:fetch).with('MDE_LINK_REQUIRED_FILE',
2069
+ nil).returns('/path/to/temp_file')
2070
+ FileUtils.expects(:rm_f).with('/path/to/temp_file').once
2071
+ @hd.expects(:clear_required_file).once
2072
+
2073
+ @hd.delete_required_temp_file
2074
+
2075
+ assert true # Placeholder for actual test assertions
2076
+ end
2077
+
2078
+ def test_delete_required_temp_file_with_no_file
2079
+ ENV.stubs(:fetch).with('MDE_LINK_REQUIRED_FILE', nil).returns(nil)
2080
+ FileUtils.expects(:rm_f).never
2081
+ @hd.expects(:clear_required_file).never
2082
+
2083
+ @hd.delete_required_temp_file
2084
+
2085
+ assert true # Placeholder for actual test assertions
2086
+ end
2087
+
2088
+ def test_delete_required_temp_file_with_error
2089
+ ENV.stubs(:fetch).with('MDE_LINK_REQUIRED_FILE',
2090
+ nil).returns('/path/to/temp_file')
2091
+ FileUtils.stubs(:rm_f).raises(StandardError)
2092
+ @hd.expects(:error_handler).with('delete_required_temp_file').once
2093
+
2094
+ @hd.delete_required_temp_file
2095
+
2096
+ assert true # Placeholder for actual test assertions
2097
+ end
2098
+ end # class TestHashDelegator
2099
+
2100
+ class TestHashDelegatorDetermineBlockState < Minitest::Test
2101
+ def setup
2102
+ @hd = HashDelegator.new
2103
+ @hd.stubs(:menu_chrome_formatted_option).returns('Formatted Option')
2104
+ end
2105
+
2106
+ def test_determine_block_state_exit
2107
+ selected_option = { oname: 'Formatted Option' }
2108
+ @hd.stubs(:menu_chrome_formatted_option).with(:menu_option_exit_name).returns('Formatted Option')
2109
+
2110
+ result = @hd.determine_block_state(selected_option)
2111
+
2112
+ assert_equal MenuState::EXIT, result.state
2113
+ assert_nil result.block
2114
+ end
2115
+
2116
+ def test_determine_block_state_back
2117
+ selected_option = { oname: 'Formatted Back Option' }
2118
+ @hd.stubs(:menu_chrome_formatted_option).with(:menu_option_back_name).returns('Formatted Back Option')
2119
+ result = @hd.determine_block_state(selected_option)
2120
+
2121
+ assert_equal MenuState::BACK, result.state
2122
+ assert_equal selected_option, result.block
2123
+ end
2124
+
2125
+ def test_determine_block_state_continue
2126
+ selected_option = { oname: 'Other Option' }
2127
+
2128
+ result = @hd.determine_block_state(selected_option)
2129
+
2130
+ assert_equal MenuState::CONTINUE, result.state
2131
+ assert_equal selected_option, result.block
2132
+ end
2133
+ end # class TestHashDelegator
2134
+
2135
+ class TestHashDelegatorDisplayRequiredCode < Minitest::Test
2136
+ def setup
2137
+ @hd = HashDelegator.new
2138
+ @hd.instance_variable_set(:@fout, mock('fout'))
2139
+ @hd.instance_variable_set(:@delegate_object, {})
2140
+ @hd.stubs(:string_send_color)
2141
+ end
2142
+
2143
+ def test_display_required_code
2144
+ required_lines = %w[line1 line2]
2145
+ @hd.instance_variable_get(:@delegate_object).stubs(:[]).with(:script_preview_head).returns('Header')
2146
+ @hd.instance_variable_get(:@delegate_object).stubs(:[]).with(:script_preview_tail).returns('Footer')
2147
+ @hd.instance_variable_get(:@fout).expects(:fout).times(4)
2148
+
2149
+ @hd.display_required_code(required_lines)
2150
+
2151
+ # Verifying that fout is called for each line and for header & footer
2152
+ assert true # Placeholder for actual test assertions
2153
+ end
2154
+ end # class TestHashDelegator
2155
+
2156
+ class TestHashDelegatorFetchColor < Minitest::Test
2157
+ def setup
2158
+ @hd = HashDelegator.new
2159
+ @hd.instance_variable_set(:@delegate_object, {})
2160
+ end
2161
+
2162
+ def test_fetch_color_with_valid_data
2163
+ @hd.instance_variable_get(:@delegate_object).stubs(:fetch).with(
2164
+ :execution_report_preview_head, ''
2165
+ ).returns('Data String')
2166
+ @hd.stubs(:string_send_color).with('Data String',
2167
+ :execution_report_preview_frame_color).returns('Colored Data String')
2168
+
2169
+ result = @hd.fetch_color
2170
+
2171
+ assert_equal 'Colored Data String', result
2172
+ end
2173
+
2174
+ def test_fetch_color_with_missing_data
2175
+ @hd.instance_variable_get(:@delegate_object).stubs(:fetch).with(
2176
+ :execution_report_preview_head, ''
2177
+ ).returns('')
2178
+ @hd.stubs(:string_send_color).with('',
2179
+ :execution_report_preview_frame_color).returns('Default Colored String')
2180
+
2181
+ result = @hd.fetch_color
2182
+
2183
+ assert_equal 'Default Colored String', result
2184
+ end
2185
+ end # class TestHashDelegator
2186
+
2187
+ class TestHashDelegatorFormatReferencesSendColor < Minitest::Test
2188
+ def setup
2189
+ @hd = HashDelegator.new
2190
+ @hd.instance_variable_set(:@delegate_object, {})
2191
+ end
2192
+
2193
+ def test_format_references_send_color_with_valid_data
2194
+ @hd.instance_variable_get(:@delegate_object).stubs(:fetch).with(
2195
+ :output_execution_label_format, ''
2196
+ ).returns('Formatted: %{key}')
2197
+ @hd.stubs(:string_send_color).returns('Colored String')
2198
+
2199
+ result = @hd.format_references_send_color(context: { key: 'value' },
2200
+ color_sym: :execution_report_preview_frame_color)
2201
+
2202
+ assert_equal 'Colored String', result
2203
+ end
2204
+
2205
+ def test_format_references_send_color_with_missing_format
2206
+ @hd.instance_variable_get(:@delegate_object).stubs(:fetch).with(
2207
+ :output_execution_label_format, ''
2208
+ ).returns('')
2209
+ @hd.stubs(:string_send_color).returns('Default Colored String')
2210
+
2211
+ result = @hd.format_references_send_color(context: { key: 'value' },
2212
+ color_sym: :execution_report_preview_frame_color)
2213
+
2214
+ assert_equal 'Default Colored String', result
2215
+ end
2216
+ end # class TestHashDelegator
2217
+
2218
+ class TestHashDelegatorFormatExecutionStreams < Minitest::Test
2219
+ def setup
2220
+ @hd = HashDelegator.new
2221
+ @hd.instance_variable_set(:@run_state, mock('run_state'))
2222
+ end
2223
+
2224
+ def test_format_execution_streams_with_valid_key
2225
+ @hd.instance_variable_get(:@run_state).stubs(:files).returns({ stdout: %w[
2226
+ output1 output2
2227
+ ] })
2228
+
2229
+ result = @hd.format_execution_streams(:stdout)
2230
+
2231
+ assert_equal 'output1output2', result
2232
+ end
2233
+
2234
+ def test_format_execution_streams_with_empty_key
2235
+ @hd.instance_variable_get(:@run_state).stubs(:files).returns({})
2236
+
2237
+ result = @hd.format_execution_streams(:stderr)
2238
+
2239
+ assert_equal '', result
2240
+ end
2241
+
2242
+ def test_format_execution_streams_with_nil_files
2243
+ @hd.instance_variable_get(:@run_state).stubs(:files).returns(nil)
2244
+
2245
+ result = @hd.format_execution_streams(:stdin)
2246
+
2247
+ assert_equal '', result
2248
+ end
2249
+ end # class TestHashDelegator
2250
+
2251
+ class TestHashDelegatorHandleBackLink < Minitest::Test
2252
+ def setup
2253
+ @hd = HashDelegator.new
2254
+ @hd.stubs(:history_state_pop)
2255
+ end
2256
+
2257
+ def test_handle_back_link
2258
+ # Verifying that history_state_pop is called
2259
+ @hd.expects(:history_state_pop).once
2260
+
2261
+ result = @hd.handle_back_link
2262
+
2263
+ # Asserting the result is an instance of LoadFileNextBlock
2264
+ assert_instance_of LoadFileNextBlock, result
2265
+ assert_equal LoadFile::Load, result.load_file
2266
+ assert_equal '', result.next_block
2267
+ end
2268
+ end # class TestHashDelegator
2269
+
2270
+ class TestHashDelegatorHandleBlockState < Minitest::Test
2271
+ def setup
2272
+ @hd = HashDelegator.new
2273
+ @mock_block_state = mock('block_state')
2274
+ end
2275
+
2276
+ def test_handle_block_state_with_back
2277
+ @mock_block_state.stubs(:state).returns(MenuState::BACK)
2278
+ @mock_block_state.stubs(:block).returns({ dname: 'sample_block' })
2279
+
2280
+ @hd.handle_block_state(@mock_block_state)
2281
+
2282
+ assert_equal 'sample_block',
2283
+ @hd.instance_variable_get(:@delegate_object)[:block_name]
2284
+ assert @hd.instance_variable_get(:@menu_user_clicked_back_link)
2285
+ end
2286
+
2287
+ def test_handle_block_state_with_continue
2288
+ @mock_block_state.stubs(:state).returns(MenuState::CONTINUE)
2289
+ @mock_block_state.stubs(:block).returns({ dname: 'another_block' })
2290
+
2291
+ @hd.handle_block_state(@mock_block_state)
2292
+
2293
+ assert_equal 'another_block',
2294
+ @hd.instance_variable_get(:@delegate_object)[:block_name]
2295
+ refute @hd.instance_variable_get(:@menu_user_clicked_back_link)
2296
+ end
2297
+
2298
+ def test_handle_block_state_with_other
2299
+ @mock_block_state.stubs(:state).returns(nil) # MenuState::OTHER
2300
+ @mock_block_state.stubs(:block).returns({ dname: 'other_block' })
2301
+
2302
+ @hd.handle_block_state(@mock_block_state)
2303
+
2304
+ assert_nil @hd.instance_variable_get(:@delegate_object)[:block_name]
2305
+ assert_nil @hd.instance_variable_get(:@menu_user_clicked_back_link)
2306
+ end
2307
+ end # class TestHashDelegator
2308
+
2309
+ class TestHashDelegatorHandleGenericBlock < Minitest::Test
2310
+ def setup
2311
+ @hd = HashDelegator.new
2312
+ @mock_document = mock('MarkdownDocument')
2313
+ @selected_item = mock('FCB')
2314
+ end
2315
+
2316
+ def test_handle_generic_block_without_user_approval
2317
+ # Mock the delegate object configuration
2318
+ @hd.instance_variable_set(:@delegate_object,
2319
+ { output_script: false,
2320
+ user_must_approve: false })
2321
+
2322
+ # Test the method without user approval
2323
+ # Expectations and assertions go here
2324
+ end
2325
+
2326
+ def test_handle_generic_block_with_user_approval
2327
+ # Mock the delegate object configuration
2328
+ @hd.instance_variable_set(:@delegate_object,
2329
+ { output_script: false,
2330
+ user_must_approve: true })
2331
+
2332
+ # Test the method with user approval
2333
+ # Expectations and assertions go here
2334
+ end
2335
+
2336
+ def test_handle_generic_block_with_output_script
2337
+ # Mock the delegate object configuration
2338
+ @hd.instance_variable_set(:@delegate_object,
2339
+ { output_script: true,
2340
+ user_must_approve: false })
2341
+
2342
+ # Test the method with output script option
2343
+ # Expectations and assertions go here
2344
+ end
2345
+ end
2346
+
2347
+ class TestHashDelegatorHandleOptsBlock < Minitest::Test
2348
+ def setup
2349
+ @hd = HashDelegator.new
2350
+ @hd.instance_variable_set(:@delegate_object,
2351
+ { menu_opts_set_format: 'Option: %<key>s, Value: %<value>s',
2352
+ menu_opts_set_color: :blue })
2353
+ @hd.stubs(:string_send_color)
2354
+ @hd.stubs(:print)
2355
+ end
2356
+
2357
+ def test_handle_opts_block
2358
+ selected = { body: ['option1: value1'] }
2359
+ tgt2 = {}
2360
+
2361
+ result = @hd.handle_opts_block(selected, tgt2)
2362
+
2363
+ assert_instance_of LoadFileNextBlock, result
2364
+ assert_equal 'value1',
2365
+ @hd.instance_variable_get(:@delegate_object)[:option1]
2366
+ assert_equal 'value1', tgt2[:option1]
2367
+ end
2368
+
2369
+ def test_handle_opts_block_without_format
2370
+ selected = { body: ['option2: value2'] }
2371
+ @hd.instance_variable_set(:@delegate_object, {})
2372
+
2373
+ result = @hd.handle_opts_block(selected)
2374
+
2375
+ assert_instance_of LoadFileNextBlock, result
2376
+ assert_equal 'value2',
2377
+ @hd.instance_variable_get(:@delegate_object)[:option2]
2378
+ end
2379
+ end
2380
+
2381
+ # require 'stringio'
2382
+
2383
+ class TestHashDelegatorHandleStream < Minitest::Test
2384
+ def setup
2385
+ @hd = HashDelegator.new
2386
+ @hd.instance_variable_set(:@run_state,
2387
+ OpenStruct.new(files: { stdout: [] }))
2388
+ @hd.instance_variable_set(:@delegate_object,
2389
+ { output_stdout: true })
2390
+ end
2391
+
2392
+ def test_handle_stream
2393
+ stream = StringIO.new("line 1\nline 2\n")
2394
+ file_type = :stdout
2395
+
2396
+ Thread.new { @hd.handle_stream(stream, file_type) }
2397
+
2398
+ @hd.wait_for_stream_processing
2399
+
2400
+ assert_equal ['line 1', 'line 2'],
2401
+ @hd.instance_variable_get(:@run_state).files[:stdout]
2402
+ end
2403
+
2404
+ def test_handle_stream_with_io_error
2405
+ stream = StringIO.new("line 1\nline 2\n")
2406
+ file_type = :stdout
2407
+ stream.stubs(:each_line).raises(IOError)
2408
+
2409
+ Thread.new { @hd.handle_stream(stream, file_type) }
2410
+
2411
+ @hd.wait_for_stream_processing
2412
+
2413
+ assert_equal [],
2414
+ @hd.instance_variable_get(:@run_state).files[:stdout]
2415
+ end
2416
+ end
2417
+
2418
+ class TestHashDelegatorHistoryStatePartition < Minitest::Test
2419
+ def setup
2420
+ @hd = HashDelegator.new
2421
+ @hd.instance_variable_set(:@delegate_object, {
2422
+ history_document_separator: '|'
2423
+ })
2424
+ end
2425
+
2426
+ def test_history_state_partition_with_value
2427
+ ENV[MDE_HISTORY_ENV_NAME] = 'part1|part2'
2428
+
2429
+ result = @hd.history_state_partition
2430
+ assert_equal({ unit: 'part1', rest: 'part2' }, result)
2431
+ end
2432
+
2433
+ def test_history_state_partition_with_no_separator
2434
+ ENV[MDE_HISTORY_ENV_NAME] = 'onlypart'
2435
+
2436
+ result = @hd.history_state_partition
2437
+ assert_equal({ unit: 'onlypart', rest: '' }, result)
2438
+ end
2439
+
2440
+ def test_history_state_partition_with_empty_env
2441
+ ENV[MDE_HISTORY_ENV_NAME] = ''
2442
+
2443
+ result = @hd.history_state_partition
2444
+ assert_equal({ unit: '', rest: '' }, result)
2445
+ end
2446
+ end
2447
+
2448
+ class TestHashDelegatorHistoryStatePop < Minitest::Test
2449
+ def setup
2450
+ @hd = HashDelegator.new
2451
+ @hd.instance_variable_set(:@delegate_object,
2452
+ { filename: 'initial.md' })
2453
+ @hd.instance_variable_set(:@run_state,
2454
+ OpenStruct.new(link_history: [{ block_name: 'block1',
2455
+ filename: 'file1.md' }]))
2456
+ @hd.stubs(:history_state_partition).returns({ unit: 'file2.md',
2457
+ rest: 'history_data' })
2458
+ @hd.stubs(:delete_required_temp_file)
2459
+ end
2460
+
2461
+ def test_history_state_pop
2462
+ ENV[MDE_HISTORY_ENV_NAME] = 'some_history'
2463
+
2464
+ @hd.history_state_pop
2465
+
2466
+ assert_equal 'file2.md',
2467
+ @hd.instance_variable_get(:@delegate_object)[:filename]
2468
+ assert_equal 'history_data',
2469
+ ENV.fetch(MDE_HISTORY_ENV_NAME, nil)
2470
+ assert_empty @hd.instance_variable_get(:@run_state).link_history
2471
+ end
2472
+ end
2473
+
2474
+ class TestHashDelegatorHistoryStatePush < Minitest::Test
2475
+ def setup
2476
+ @hd = HashDelegator.new
2477
+ @hd.instance_variable_set(:@delegate_object, {
2478
+ filename: 'test.md',
2479
+ block_name: 'test_block',
2480
+ history_document_separator: '||'
2481
+ })
2482
+ @hd.instance_variable_set(:@run_state,
2483
+ OpenStruct.new(link_history: []))
2484
+ @hd.stubs(:write_required_blocks_to_temp_file)
2485
+ end
2486
+
2487
+ def test_history_state_push
2488
+ mdoc = 'markdown content'
2489
+ data_file = 'data.md'
2490
+ selected = { oname: 'selected_block' }
2491
+
2492
+ ENV[MDE_HISTORY_ENV_NAME] = 'existing_history'
2493
+
2494
+ @hd.history_state_push(mdoc, data_file, selected)
2495
+
2496
+ assert_equal 'data.md',
2497
+ @hd.instance_variable_get(:@delegate_object)[:filename]
2498
+ assert_equal 'test.md||existing_history',
2499
+ ENV.fetch(MDE_HISTORY_ENV_NAME, nil)
2500
+ assert_includes @hd.instance_variable_get(:@run_state).link_history,
2501
+ { block_name: 'selected_block',
2502
+ filename: 'data.md' }
2503
+ end
2504
+ end
2505
+
2506
+ class TestHashDelegatorIterBlocksFromNestedFiles < Minitest::Test
2507
+ def setup
2508
+ @hd = HashDelegator.new
2509
+ @hd.instance_variable_set(:@delegate_object,
2510
+ { filename: 'test.md' })
2511
+ @hd.stubs(:check_file_existence).with('test.md').returns(true)
2512
+ @hd.stubs(:initial_state).returns({})
2513
+ @hd.stubs(:cfile).returns(Minitest::Mock.new)
2514
+ @hd.stubs(:update_line_and_block_state)
2515
+ end
2516
+
2517
+ def test_iter_blocks_from_nested_files
2518
+ @hd.cfile.expect(:readlines, ['line 1', 'line 2'], ['test.md'])
2519
+ selected_messages = ['filtered message']
2520
+
2521
+ result = @hd.iter_blocks_from_nested_files { selected_messages }
2522
+ assert_equal ['line 1', 'line 2'], result
2523
+
2524
+ @hd.cfile.verify
2525
+ end
2526
+
2527
+ def test_iter_blocks_from_nested_files_with_no_file
2528
+ @hd.stubs(:check_file_existence).with('test.md').returns(false)
2529
+
2530
+ assert_nil(@hd.iter_blocks_from_nested_files do
2531
+ ['filtered message']
2532
+ end)
2533
+ end
2534
+ end
2535
+
2536
+ class TestHashDelegatorLoadAutoBlocks < Minitest::Test
2537
+ def setup
2538
+ @hd = HashDelegator.new
2539
+ @hd.stubs(:block_find).returns({})
2540
+ @hd.stubs(:handle_opts_block)
2541
+ end
2542
+
2543
+ def test_load_auto_blocks_with_new_filename
2544
+ @hd.instance_variable_set(:@delegate_object, {
2545
+ document_load_opts_block_name: 'load_block',
2546
+ filename: 'new_file'
2547
+ })
2548
+ assert @hd.load_auto_blocks([])
2549
+ end
2550
+
2551
+ def test_load_auto_blocks_with_same_filename
2552
+ @hd.instance_variable_set(:@delegate_object, {
2553
+ document_load_opts_block_name: 'load_block',
2554
+ filename: 'new_file'
2555
+ })
2556
+ @hd.instance_variable_set(:@most_recent_loaded_filename, 'new_file')
2557
+ assert_nil @hd.load_auto_blocks([])
2558
+ end
2559
+
2560
+ def test_load_auto_blocks_without_block_name
2561
+ @hd.instance_variable_set(:@delegate_object, {
2562
+ document_load_opts_block_name: nil,
2563
+ filename: 'new_file'
2564
+ })
2565
+ assert_nil @hd.load_auto_blocks([])
2566
+ end
2567
+ end
2568
+
2569
+ class TestHashDelegatorMenuChromeColoredOption < Minitest::Test
2570
+ def setup
2571
+ @hd = HashDelegator.new
2572
+ @hd.instance_variable_set(:@delegate_object, {
2573
+ menu_option_back_name: 'Back',
2574
+ menu_chrome_color: :red,
2575
+ menu_chrome_format: '-- %s --'
2576
+ })
2577
+ @hd.stubs(:menu_chrome_formatted_option).with(:menu_option_back_name).returns('-- Back --')
2578
+ @hd.stubs(:string_send_color).with('-- Back --',
2579
+ :menu_chrome_color).returns('-- Back --'.red)
2580
+ end
2581
+
2582
+ def test_menu_chrome_colored_option_with_color
2583
+ assert_equal '-- Back --'.red,
2584
+ @hd.menu_chrome_colored_option(:menu_option_back_name)
2585
+ end
2586
+
2587
+ def test_menu_chrome_colored_option_without_color
2588
+ @hd.instance_variable_set(:@delegate_object,
2589
+ { menu_option_back_name: 'Back' })
2590
+ assert_equal '-- Back --',
2591
+ @hd.menu_chrome_colored_option(:menu_option_back_name)
2592
+ end
2593
+ end
2594
+
2595
+ class TestHashDelegatorMenuChromeFormattedOptionWithoutFormat < Minitest::Test
2596
+ def setup
2597
+ @hd = HashDelegator.new
2598
+ @hd.instance_variable_set(:@delegate_object, {
2599
+ menu_option_back_name: "'Back'",
2600
+ menu_chrome_format: '-- %s --'
2601
+ })
2602
+ @hd.stubs(:safeval).with("'Back'").returns('Back')
2603
+ end
2604
+
2605
+ def test_menu_chrome_formatted_option_with_format
2606
+ assert_equal '-- Back --',
2607
+ @hd.menu_chrome_formatted_option(:menu_option_back_name)
2608
+ end
2609
+
2610
+ def test_menu_chrome_formatted_option_without_format
2611
+ @hd.instance_variable_set(:@delegate_object,
2612
+ { menu_option_back_name: "'Back'" })
2613
+ assert_equal 'Back',
2614
+ @hd.menu_chrome_formatted_option(:menu_option_back_name)
2615
+ end
2616
+ end
2617
+
2618
+ class TestHashDelegatorStartFencedBlock < Minitest::Test
2619
+ def setup
2620
+ @hd = HashDelegator.new({
2621
+ block_name_wrapper_match: 'WRAPPER_REGEX',
2622
+ block_calls_scan: 'CALLS_REGEX'
2623
+ })
2624
+ end
2625
+
2626
+ def test_start_fenced_block
2627
+ line = '```fenced'
2628
+ headings = ['Heading 1']
2629
+ regex = /```(?<name>\w+)(?<rest>.*)/
2630
+
2631
+ fcb = @hd.start_fenced_block(line, headings, regex)
2632
+
2633
+ assert_instance_of MarkdownExec::FCB, fcb
2634
+ assert_equal headings, fcb.headings
2635
+ assert_equal 'fenced', fcb.dname
2636
+ end
2637
+ end
2638
+
2639
+ class TestHashDelegatorStringSendColor < Minitest::Test
2640
+ def setup
2641
+ @hd = HashDelegator.new
2642
+ @hd.instance_variable_set(:@delegate_object,
2643
+ { red: 'red', green: 'green' })
2644
+ end
2645
+
2646
+ def test_string_send_color
2647
+ assert_equal 'Hello'.red, @hd.string_send_color('Hello', :red)
2648
+ assert_equal 'World'.green,
2649
+ @hd.string_send_color('World', :green)
2650
+ assert_equal 'Default'.plain,
2651
+ @hd.string_send_color('Default', :blue)
2652
+ end
2653
+ end
2654
+
2655
+ def test_yield_line_if_selected_with_line
2656
+ block_called = false
2657
+ @hd.yield_line_if_selected('Test line', [:line]) do |type, content|
2658
+ block_called = true
2659
+ assert_equal :line, type
2660
+ assert_equal 'Test line', content.body[0]
2661
+ end
2662
+ assert block_called
2663
+ end
2664
+
2665
+ def test_yield_line_if_selected_without_line
2666
+ block_called = false
2667
+ @hd.yield_line_if_selected('Test line', [:other]) do |_|
2668
+ block_called = true
2669
+ end
2670
+ refute block_called
2671
+ end
2672
+
2673
+ def test_yield_line_if_selected_without_block
2674
+ result = @hd.yield_line_if_selected('Test line', [:line])
2675
+ assert_nil result
2676
+ end
2677
+ end # class TestHashDelegator
2678
+
2679
+ class TestHashDelegator < Minitest::Test
2680
+ def setup
2681
+ @hd = HashDelegator.new
2682
+ @hd.instance_variable_set(:@delegate_object, {
2683
+ heading1_match: '^# (?<name>.+)$',
2684
+ heading2_match: '^## (?<name>.+)$',
2685
+ heading3_match: '^### (?<name>.+)$'
2686
+ })
2687
+ end
2688
+
2689
+ def test_update_document_headings
2690
+ assert_equal(['Heading 1'],
2691
+ @hd.update_document_headings('# Heading 1', []))
2692
+ assert_equal(['Heading 1', 'Heading 2'],
2693
+ @hd.update_document_headings('## Heading 2',
2694
+ ['Heading 1']))
2695
+ assert_equal(['Heading 1', 'Heading 2', 'Heading 3'],
2696
+ @hd.update_document_headings('### Heading 3',
2697
+ ['Heading 1', 'Heading 2']))
2698
+ assert_equal([], @hd.update_document_headings('Regular text', []))
2699
+ end
2700
+ end
2701
+
2702
+ class TestHashDelegatorUpdateMenuAttribYieldSelectedWithBody < Minitest::Test
2703
+ def setup
2704
+ @hd = HashDelegator.new
2705
+ @fcb = mock('Fcb')
2706
+ @fcb.stubs(:body).returns(true)
2707
+ @hd.stubs(:initialize_fcb_names)
2708
+ @hd.stubs(:default_block_title_from_body)
2709
+ @hd.stubs(:yield_to_block_if_applicable)
2710
+ end
2711
+
2712
+ def test_update_menu_attrib_yield_selected_with_body
2713
+ @hd.expects(:initialize_fcb_names).with(@fcb)
2714
+ @hd.expects(:default_block_title_from_body).with(@fcb)
2715
+ @hd.expects(:yield_to_block_if_applicable).with(@fcb, [:some_message])
2716
+
2717
+ @hd.update_menu_attrib_yield_selected(@fcb, [:some_message])
2718
+ end
2719
+
2720
+ def test_update_menu_attrib_yield_selected_without_body
2721
+ @fcb.stubs(:body).returns(nil)
2722
+ @hd.expects(:initialize_fcb_names).with(@fcb)
2723
+ @hd.update_menu_attrib_yield_selected(@fcb, [:some_message])
2724
+ end
2725
+ end
2726
+
2727
+ class TestHashDelegatorWaitForUserSelectedBlock < Minitest::Test
2728
+ def setup
2729
+ @hd = HashDelegator.new
2730
+ @hd.stubs(:error_handler)
2731
+ end
2732
+
2733
+ def test_wait_for_user_selected_block_with_back_state
2734
+ mock_block_state = Struct.new(:state, :block).new(MenuState::BACK,
2735
+ { dname: 'back_block' })
2736
+ @hd.stubs(:wait_for_user_selection).returns(mock_block_state)
2737
+
2738
+ result = @hd.wait_for_user_selected_block([], ['Block 1', 'Block 2'],
2739
+ nil)
2740
+
2741
+ assert_equal 'back_block',
2742
+ @hd.instance_variable_get(:@delegate_object)[:block_name]
2743
+ assert @hd.instance_variable_get(:@menu_user_clicked_back_link)
2744
+ assert_equal mock_block_state, result
2745
+ end
2746
+
2747
+ def test_wait_for_user_selected_block_with_continue_state
2748
+ mock_block_state = Struct.new(:state, :block).new(
2749
+ MenuState::CONTINUE, { dname: 'continue_block' }
2750
+ )
2751
+ @hd.stubs(:wait_for_user_selection).returns(mock_block_state)
2752
+
2753
+ result = @hd.wait_for_user_selected_block([], ['Block 1', 'Block 2'],
2754
+ nil)
2755
+
2756
+ assert_equal 'continue_block',
2757
+ @hd.instance_variable_get(:@delegate_object)[:block_name]
2758
+ refute @hd.instance_variable_get(:@menu_user_clicked_back_link)
2759
+ assert_equal mock_block_state, result
2760
+ end
2761
+ end
2762
+
2763
+ ####
2764
+ class TestHashDelegatorYieldToBlock < Minitest::Test
2765
+ def setup
2766
+ @hd = HashDelegator.new
2767
+ @fcb = mock('Fcb')
2768
+ MarkdownExec::Filter.stubs(:fcb_select?).returns(true)
2769
+ end
2770
+
2771
+ def test_yield_to_block_if_applicable_with_correct_conditions
2772
+ block_called = false
2773
+ @hd.yield_to_block_if_applicable(@fcb, [:blocks]) do |type, fcb|
2774
+ block_called = true
2775
+ assert_equal :blocks, type
2776
+ assert_equal @fcb, fcb
2777
+ end
2778
+ assert block_called
2779
+ end
2780
+
2781
+ def test_yield_to_block_if_applicable_without_block
2782
+ result = @hd.yield_to_block_if_applicable(@fcb, [:blocks])
2783
+ assert_nil result
2784
+ end
2785
+
2786
+ def test_yield_to_block_if_applicable_with_incorrect_conditions
2787
+ block_called = false
2788
+ MarkdownExec::Filter.stubs(:fcb_select?).returns(false)
2789
+ @hd.yield_to_block_if_applicable(@fcb, [:non_blocks]) do |_|
2790
+ block_called = true
2791
+ end
2792
+ refute block_called
2793
+ end
2794
+ end
2795
+ end # module MarkdownExec
2796
+ end