lib/hash_delegator.rb in markdown_exec-1.7 vs lib/hash_delegator.rb in markdown_exec-1.8

- old
+ new

@@ -16,10 +16,11 @@ require_relative 'array_util' require_relative 'block_label' require_relative 'block_types' require_relative 'cached_nested_file_reader' require_relative 'constants' +require_relative 'directory_searcher' require_relative 'exceptions' require_relative 'fcb' require_relative 'filter' require_relative 'fout' require_relative 'hash' @@ -32,10 +33,60 @@ def non_empty? !empty? end end +# This module provides methods for compacting and converting data structures. +module CompactionHelpers + # Converts an array of key-value pairs into a hash, applying compaction to the values. + # Each value is processed by `compact_hash` to remove ineligible elements. + # + # @param array [Array] The array of key-value pairs to be converted. + # @return [Hash] A hash with keys from the array and compacted values. + def compact_and_convert_array_to_hash(array) + array.transform_values do |value| + compact_hash(value) + end + end + + # Compacts a hash by removing ineligible elements. + # It filters out nil, empty arrays, empty hashes, and empty strings from its values. + # It also removes entries with :random as the key. + # + # @param hash [Hash] The hash to be compacted. + # @return [Hash] A compacted version of the input hash. + def compact_hash(hash) + hash.map do |key, value| + next if value_ineligible?(value) || key == :random + + [key, value] + end.compact.to_h + end + + # Converts a hash into another hash with indexed keys, applying compaction to the values. + # The keys are indexed, and the values are compacted using `compact_and_convert_array_to_hash`. + # + # @param hash [Hash] The hash to be converted and compacted. + # @return [Hash] A hash with indexed keys and the compacted original values. + def compact_and_index_hash(hash) + compact_and_convert_array_to_hash(hash.map.with_index do |value, index| + [index, value] + end.to_h) + end + + private + + # Determines if a value is ineligible for inclusion in a compacted hash. + # Ineligible values are nil, empty arrays, empty hashes, and empty strings. + # + # @param value [Object] The value to be checked. + # @return [Boolean] True if the value is ineligible, false otherwise. + def value_ineligible?(value) + [nil, [], {}, ''].include?(value) + end +end + module MarkdownExec class DebugHelper # Class-level variable to store history of printed messages @@printed_messages = Set.new @@ -49,16 +100,20 @@ @@printed_messages.add(str) end end class HashDelegator - attr_accessor :run_state + attr_accessor :most_recent_loaded_filename, :pass_args, :run_state + include CompactionHelpers + def initialize(delegate_object = {}) @delegate_object = delegate_object @prompt = tty_prompt_without_disabled_symbol + @most_recent_loaded_filename = nil + @pass_args = [] @run_state = OpenStruct.new( link_history: [] ) @fout = FOut.new(@delegate_object) ### slice only relevant keys @@ -265,24 +320,52 @@ return false end true end + def runtime_exception(exception_sym, name, items) + if @delegate_object[exception_sym] != 0 + data = { name: name, detail: items.join(', ') } + warn( + format( + @delegate_object.fetch(:exception_format_name, "\n%{name}"), + data + ).send(@delegate_object.fetch(:exception_color_name, :red)) + + format( + @delegate_object.fetch(:exception_format_detail, " - %{detail}\n"), + data + ).send(@delegate_object.fetch(:exception_color_detail, :yellow)) + ) + end + return unless (@delegate_object[exception_sym]).positive? + + exit @delegate_object[exception_sym] + end + # Collects required code lines based on the selected block and the delegate object's configuration. # If the block type is VARS, it also sets environment variables based on the block's content. # # @param mdoc [YourMDocClass] An instance of the MDoc class. # @param selected [Hash] The selected block. # @return [Array<String>] Required code blocks as an array of lines. def collect_required_code_lines(mdoc, selected) - if selected[:shell] == BlockType::VARS - set_environment_variables(selected) - end + set_environment_variables(selected) if selected[:shell] == BlockType::VARS required = mdoc.collect_recursively_required_code( - @delegate_object[:block_name], opts: @delegate_object + @delegate_object[:block_name], + label_format_above: @delegate_object[:shell_code_label_format_above], + label_format_below: @delegate_object[:shell_code_label_format_below] ) + if required[:unmet_dependencies].present? + warn format_and_highlight_dependencies(required[:dependencies], + highlight: required[:unmet_dependencies]) + runtime_exception(:runtime_exception_error_level, + 'unmet_dependencies, flag: runtime_exception_error_level', required[:unmet_dependencies]) + elsif true + warn format_and_highlight_dependencies(required[:dependencies], + highlight: [@delegate_object[:block_name]]) + end read_required_blocks_from_temp_file + required[:code] end # private @@ -487,15 +570,15 @@ return unless fcb.title.nil? || fcb.title.empty? fcb.derive_title_from_body end - def delete_blank_lines_next_to_chrome!(blocks_menu) - blocks_menu.process_and_conditionally_delete! do |prev_item, current_item, next_item| - (prev_item&.fetch(:chrome, nil) || next_item&.fetch(:chrome, nil)) && - current_item&.fetch(:chrome, nil) && - !current_item&.fetch(:oname).present? + # delete the current line if it is empty and the previous is also empty + def delete_consecutive_blank_lines!(blocks_menu) + blocks_menu.process_and_conditionally_delete! do |prev_item, current_item, _next_item| + prev_item&.fetch(:chrome, nil) && !prev_item&.fetch(:oname).present? && + current_item&.fetch(:chrome, nil) && !current_item&.fetch(:oname).present? end end # Deletes a temporary file specified by an environment variable. # Checks if the file exists before attempting to delete it and clears the environment variable afterward. @@ -573,12 +656,12 @@ # It sets the script block name, writes command files if required, and handles the execution # including output formatting and summarization. # # @param required_lines [Array<String>] The lines of code to be executed. # @param selected [FCB] The selected functional code block object. - def execute_approved_block(required_lines = [], selected = FCB.new) - set_script_block_name(selected) + def execute_approved_block(required_lines = [], _selected = FCB.new) + # set_script_block_name(selected) write_command_file_if_needed(required_lines) format_and_execute_command(required_lines) post_execution_process end @@ -594,12 +677,11 @@ def format_and_execute_command(lines) formatted_command = lines.flatten.join("\n") @fout.fout fetch_color(data_sym: :script_execution_head, color_sym: :script_execution_frame_color) - command_execute(formatted_command, - args: @delegate_object.fetch(:s_pass_args, [])) + command_execute(formatted_command, args: @pass_args) @fout.fout fetch_color(data_sym: :script_execution_tail, color_sym: :script_execution_frame_color) end def post_execution_process @@ -720,16 +802,12 @@ # @param selected [Hash] The selected item from the menu to be executed. # @return [LoadFileNextBlock] An object indicating whether to load the next block or reuse the current one. def handle_generic_block(mdoc, selected) required_lines = collect_required_code_lines(mdoc, selected) output_or_approval = @delegate_object[:output_script] || @delegate_object[:user_must_approve] - display_required_code(required_lines) if output_or_approval - allow_execution = @delegate_object[:user_must_approve] ? prompt_for_user_approval(required_lines) : true - - @delegate_object[:s_ir_approve] = allow_execution execute_approved_block(required_lines, selected) if allow_execution LoadFileNextBlock.new(LoadFile::Reuse, '') end @@ -767,11 +845,11 @@ # @param selected [Hash] Selected item from the menu containing a YAML body. # @param tgt2 [Hash, nil] An optional target hash to update with YAML data. # @return [LoadFileNextBlock] An instance indicating the next action for loading files. def handle_opts_block(selected, tgt2 = nil) data = YAML.load(selected[:body].join("\n")) - data.each do |key, value| + (data || []).each do |key, value| update_delegate_and_target(key, value, tgt2) if @delegate_object[:menu_opts_set_format].present? print_formatted_option(key, value) end @@ -929,19 +1007,19 @@ # Executes a specified block once per filename. # @param all_blocks [Array] Array of all block elements. # @return [Boolean, nil] True if values were modified, nil otherwise. def load_auto_blocks(all_blocks) block_name = @delegate_object[:document_load_opts_block_name] - unless block_name.present? && @delegate_object[:s_most_recent_filename] != @delegate_object[:filename] + unless block_name.present? && @most_recent_loaded_filename != @delegate_object[:filename] return end block = block_find(all_blocks, :oname, block_name) return unless block handle_opts_block(block, @delegate_object) - @delegate_object[:s_most_recent_filename] = @delegate_object[:filename] + @most_recent_loaded_filename = @delegate_object[:filename] true end # DebugHelper.d ["HDmm method_name: #{method_name}", "#{first_n_caller_items 1}"] def first_n_caller_items(n) @@ -997,11 +1075,11 @@ all_blocks, mdoc = mdoc_and_blocks_from_nested_files end menu_blocks = mdoc.fcbs_per_options(@delegate_object) add_menu_chrome_blocks!(menu_blocks) - delete_blank_lines_next_to_chrome!(menu_blocks) + delete_consecutive_blank_lines!(menu_blocks) if true ### compress empty lines [all_blocks, menu_blocks, mdoc] end # Formats and optionally colors a menu option based on delegate object's configuration. # @param option_symbol [Symbol] The symbol key for the menu option in the delegate object. @@ -1026,13 +1104,13 @@ option_value end end def next_block_name_from_command_line_arguments - return MenuControl::Repeat unless @delegate_object[:s_cli_rest].present? + return MenuControl::Repeat unless @delegate_object[:input_cli_rest].present? - @delegate_object[:block_name] = @delegate_object[:s_cli_rest].pop + @delegate_object[:block_name] = @delegate_object[:input_cli_rest].pop MenuControl::Fresh end def output_execution_result @fout.fout fetch_color(data_sym: :execution_report_preview_head, @@ -1207,11 +1285,11 @@ # # This method allows the user to interactively select a code block from a # Markdown document, obtain approval, and execute the chosen block of code. # # @return [Nil] Returns nil if no code block is selected or an error occurs. - def select_approve_and_execute_block + def select_approve_and_execute_block(_execute: true) @menu_base_options = @delegate_object repeat_menu = @menu_base_options[:block_name].present? ? MenuControl::Fresh : MenuControl::Repeat load_file_next_block = LoadFileNextBlock.new(LoadFile::Reuse) default = nil @@ -1223,23 +1301,39 @@ @delegate_object = @menu_base_options.dup @menu_base_options[:filename] = @menu_state_filename @menu_base_options[:block_name] = @menu_state_block_name @menu_state_filename = nil @menu_state_block_name = nil - @menu_user_clicked_back_link = false + blocks_in_file, menu_blocks, mdoc = mdoc_menu_and_blocks_from_nested_files + if @delegate_object[:dump_blocks_in_file] + warn format_and_highlight_dependencies( + compact_and_index_hash(blocks_in_file), + label: 'blocks_in_file' + ) + end + if @delegate_object[:dump_menu_blocks] + warn format_and_highlight_dependencies( + compact_and_index_hash(menu_blocks), + label: 'menu_blocks' + ) + end block_state = command_or_user_selected_block(blocks_in_file, menu_blocks, default) return if block_state.state == MenuState::EXIT if block_state.block.nil? warn_format('select_approve_and_execute_block', "Block not found -- #{@delegate_object[:block_name]}", { abort: true }) # error_handler("Block not found -- #{opts[:block_name]}", { abort: true }) end + if @delegate_object[:dump_selected_block] + warn block_state.block.to_yaml.sub(/^(?:---\n)?/, "Block:\n") + end + load_file_next_block = approve_and_execute_block(block_state.block, mdoc) default = load_file_next_block.load_file == LoadFile::Load ? nil : @delegate_object[:block_name] @menu_base_options[:block_name] = @delegate_object[:block_name] = load_file_next_block.next_block @@ -1467,15 +1561,19 @@ prompt_title = string_send_color( @delegate_object[:prompt_select_block].to_s, :prompt_color_after_script_execution ) block_menu = prepare_blocks_menu(menu_blocks) - if block_menu.empty? - return SelectedBlockMenuState.new(nil, MenuState::EXIT) - end + return SelectedBlockMenuState.new(nil, MenuState::EXIT) if block_menu.empty? - selection_opts = default ? @delegate_object.merge(default: default) : @delegate_object + # default value may not match if color is different from originating menu (opts changed while processing) + selection_opts = if default && menu_blocks.map(&:dname).include?(default) + @delegate_object.merge(default: default) + else + @delegate_object + end + selection_opts.merge!(per_page: @delegate_object[:select_page_height]) selected_option = select_option_with_metadata(prompt_title, block_menu, selection_opts) determine_block_state(selected_option) @@ -1538,11 +1636,12 @@ # @param block_name [String] The name of the block to collect code for. def write_required_blocks_to_temp_file(mdoc, block_name) c1 = if mdoc mdoc.collect_recursively_required_code( block_name, - opts: @delegate_object + label_format_above: @delegate_object[:shell_code_label_format_above], + label_format_below: @delegate_object[:shell_code_label_format_below] )[:code] else [] end @@ -1595,20 +1694,18 @@ def test_calling_execute_approved_block_calls_command_execute_with_argument_args_value pigeon = 'E' obj = { output_execution_label_format: '', output_execution_label_name_color: 'plain', - output_execution_label_value_color: 'plain', - s_pass_args: pigeon - # shell: 'bash' + output_execution_label_value_color: 'plain' } c = MarkdownExec::HashDelegator.new(obj) + c.pass_args = pigeon # Expect that method opts_command_execute is called with argument args having value pigeon c.expects(:command_execute).with( - # obj, '', args: pigeon ) # Call method opts_execute_approved_block @@ -2277,12 +2374,10 @@ assert_instance_of LoadFileNextBlock, result assert_equal 'value2', @hd.instance_variable_get(:@delegate_object)[:option2] end - - # Additional test cases can be added to cover more scenarios and edge cases. end # require 'stringio' class TestHashDelegatorHandleStream < Minitest::Test @@ -2346,12 +2441,10 @@ ENV[MDE_HISTORY_ENV_NAME] = '' result = @hd.history_state_partition assert_equal({ unit: '', rest: '' }, result) end - - # Additional test cases can be added to cover more scenarios and edge cases. end class TestHashDelegatorHistoryStatePop < Minitest::Test def setup @hd = HashDelegator.new @@ -2374,12 +2467,10 @@ @hd.instance_variable_get(:@delegate_object)[:filename] assert_equal 'history_data', ENV.fetch(MDE_HISTORY_ENV_NAME, nil) assert_empty @hd.instance_variable_get(:@run_state).link_history end - - # Additional test cases can be added to cover more scenarios and edge cases. end class TestHashDelegatorHistoryStatePush < Minitest::Test def setup @hd = HashDelegator.new @@ -2408,12 +2499,10 @@ ENV.fetch(MDE_HISTORY_ENV_NAME, nil) assert_includes @hd.instance_variable_get(:@run_state).link_history, { block_name: 'selected_block', filename: 'data.md' } end - - # Additional test cases can be added to cover more scenarios and edge cases. end class TestHashDelegatorIterBlocksFromNestedFiles < Minitest::Test def setup @hd = HashDelegator.new @@ -2445,35 +2534,33 @@ end class TestHashDelegatorLoadAutoBlocks < Minitest::Test def setup @hd = HashDelegator.new - @hd.instance_variable_set(:@delegate_object, { - document_load_opts_block_name: 'load_block', - s_most_recent_filename: 'old_file', - filename: 'new_file' - }) - @hd.stubs(:block_find).returns({}) # Assuming it returns a block + @hd.stubs(:block_find).returns({}) @hd.stubs(:handle_opts_block) end def test_load_auto_blocks_with_new_filename + @hd.instance_variable_set(:@delegate_object, { + document_load_opts_block_name: 'load_block', + filename: 'new_file' + }) assert @hd.load_auto_blocks([]) end def test_load_auto_blocks_with_same_filename @hd.instance_variable_set(:@delegate_object, { document_load_opts_block_name: 'load_block', - s_most_recent_filename: 'new_file', filename: 'new_file' }) + @hd.instance_variable_set(:@most_recent_loaded_filename, 'new_file') assert_nil @hd.load_auto_blocks([]) end def test_load_auto_blocks_without_block_name @hd.instance_variable_set(:@delegate_object, { document_load_opts_block_name: nil, - s_most_recent_filename: 'old_file', filename: 'new_file' }) assert_nil @hd.load_auto_blocks([]) end end