markdown_exec 1.6 → 1.7

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