markdown_exec 1.6 → 1.8

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