lib/hash_delegator.rb in markdown_exec-2.3.0 vs lib/hash_delegator.rb in markdown_exec-2.4.0

- old
+ new

@@ -15,33 +15,37 @@ require 'tempfile' require 'tmpdir' require 'tty-prompt' require 'yaml' +require_relative 'ansi_string' require_relative 'array' 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 'format_table' require_relative 'fout' require_relative 'hash' require_relative 'hierarchy_string' require_relative 'link_history' require_relative 'mdoc' require_relative 'namer' require_relative 'regexp' require_relative 'resize_terminal' -require_relative 'std_out_err_logger' require_relative 'streams_out' require_relative 'string_util' +require_relative 'table_extractor' require_relative 'text_analyzer' +require_relative 'argument_processor' + $pd = false unless defined?($pd) class String # Checks if the string is not empty. # @return [Boolean] Returns true if the string is not empty, false otherwise. @@ -50,21 +54,24 @@ end end module HashDelegatorSelf # Applies an ANSI color method to a string using a specified color key. - # The method retrieves the color method from the provided hash. If the color key - # is not present in the hash, it uses a default color method. + # The method retrieves the color method from the provided hash. If the + # color key is not present in the hash, it uses a default color method. # @param string [String] The string to be colored. - # @param color_methods [Hash] A hash where keys are color names (String/Symbol) and values are color methods. - # @param color_key [String, Symbol] The key representing the desired color method in the color_methods hash. - # @param default_method [String] (optional) Default color method to use if color_key is not found in color_methods. Defaults to 'plain'. + # @param color_methods [Hash] A hash where keys are color names + # (String/Symbol) and values are color methods. + # @param color_key [String, Symbol] The key representing the desired + # color method in the color_methods hash. + # @param default_method [String] (optional) Default color method to + # use if color_key is not found in color_methods. Defaults to 'plain'. # @return [String] The colored string. def apply_color_from_hash(string, color_methods, color_key, default_method: 'plain') color_method = color_methods.fetch(color_key, default_method).to_sym - string.to_s.send(color_method) + AnsiString.new(string.to_s).send(color_method) end # # Enhanced `apply_color_from_hash` method to support dynamic color transformations # # @param string [String] The string to be colored. # # @param color_transformations [Hash] A hash mapping color names to lambdas that apply color transformations. @@ -82,19 +89,25 @@ # } # string = "Hello, World!" # colored_string = apply_color_from_hash(string, color_transformations, :red) # puts colored_string # This will print the string in red - # Searches for the first element in a collection where the specified message sent to an element matches a given value. - # This method is particularly useful for finding a specific hash-like object within an enumerable collection. + # Searches for the first element in a collection where the specified + # message sent to an element matches a given value. + # This method is particularly useful for finding a specific hash-like + # object within an enumerable collection. # If no match is found, it returns a specified default value. # # @param blocks [Enumerable] The collection of hash-like objects to search. - # @param msg [Symbol, String] The message to send to each element of the collection. - # @param value [Object] The value to match against the result of the message sent to each element. - # @param default [Object, nil] The default value to return if no match is found (optional). - # @return [Object, nil] The first matching element or the default value if no match is found. + # @param msg [Symbol, String] The message to send to each element of + # the collection. + # @param value [Object] The value to match against the result of the message + # sent to each element. + # @param default [Object, nil] The default value to return if no match is + # found (optional). + # @return [Object, nil] The first matching element or the default value if + # no match is found. def block_find(blocks, msg, value, default = nil) blocks.find { |item| item.send(msg) == value } || default end def code_merge(*bodies) @@ -108,15 +121,17 @@ def create_directory_for_file(file_path) FileUtils.mkdir_p(File.dirname(file_path)) end # Creates a file at the specified path, writes the given content to it, - # and sets file permissions if required. Handles any errors encountered during the process. + # and sets file permissions if required. Handles any errors encountered + # during the process. # # @param file_path [String] The path where the file will be created. # @param content [String] The content to write into the file. - # @param chmod_value [Integer] The file permission value to set; skips if zero. + # @param chmod_value [Integer] The file permission value to set; + # skips if zero. def create_file_and_write_string_with_permissions(file_path, content, chmod_value) create_directory_for_file(file_path) File.write(file_path, content) set_file_permissions(file_path, chmod_value) unless chmod_value.zero? @@ -126,11 +141,12 @@ # def create_temp_file # Dir::Tmpname.create(self.class.to_s) { |path| path } # end - # Updates the title of an FCB object from its body content if the title is nil or empty. + # Updates the title of an FCB object from its body content if the title + # is nil or empty. def default_block_title_from_body(fcb) return unless fcb.title.nil? || fcb.title.empty? fcb.derive_title_from_body end @@ -172,11 +188,12 @@ # end.join("\n") # end # Indents all lines in a given string with a specified indentation string. # @param body [String] A multi-line string to be indented. - # @param indent [String] The string used for indentation (default is an empty string). + # @param indent [String] The string used for indentation + # (default is an empty string). # @return [String] A single string with each line indented as specified. def indent_all_lines(body, indent = nil) return body unless indent&.non_empty? body.lines.map { |line| indent + line.chomp }.join("\n") @@ -269,17 +286,55 @@ def set_file_permissions(file_path, chmod_value) File.chmod(chmod_value, file_path) end + # find tables in multiple lines and format horizontally + def tables_into_columns!(blocks_menu, delegate_object) + return unless delegate_object[:tables_into_columns] + + lines = blocks_menu.map(&:oname) + text_tables = TableExtractor.extract_tables(lines) + return unless text_tables.count.positive? + + text_tables.each do |match| + range = match[:start_index]..(match[:start_index] + match[:rows] - 1) + lines = blocks_menu[range].map(&:oname) + formatted = MarkdownTableFormatter.format_table( + lines, + match[:columns], + decorate: { + border: delegate_object[:table_border_color], + header_row: delegate_object[:table_header_row_color], + row: delegate_object[:table_row_color], + separator_line: delegate_object[:table_separator_line_color] + } + ) + + if formatted.count == range.size + # read indentation from first line + indent = blocks_menu[range.first].oname.split('|', 2).first + + # replace text in each block + range.each.with_index do |block_ind, ind| + ### format oname to dname + blocks_menu[block_ind].dname = indent + formatted[ind] + end + else + warn [__LINE__, range, lines, formatted].inspect + raise 'Invalid result from MarkdownTableFormatter.format_table()' + end + end + end + # Creates a TTY prompt with custom settings. Specifically, it disables the default 'cross' symbol and # defines a lambda function to handle interrupts. # @return [TTY::Prompt] A new TTY::Prompt instance with specified configurations. def tty_prompt_without_disabled_symbol TTY::Prompt.new( interrupt: lambda { - puts + puts # next line in case not at start raise TTY::Reader::InputInterrupt }, symbols: { cross: ' ' } ) end @@ -287,11 +342,11 @@ # Updates the attributes of the given fcb object and conditionally yields to a block. # It initializes fcb names and sets the default block title from fcb's body. # If the fcb has a body and meets certain conditions, it yields to the given block. # # @param fcb [Object] The fcb object whose attributes are to be updated. - # @param selected_messages [Array<Symbol>] A list of message types to determine if yielding is applicable. + # @param selected_types [Array<Symbol>] A list of message types to determine if yielding is applicable. # @param block [Block] An optional block to yield to if conditions are met. def update_menu_attrib_yield_selected(fcb:, messages:, configuration: {}, &block) initialize_fcb_names(fcb) return unless fcb.body @@ -301,14 +356,14 @@ &block) end # Yields a line as a new block if the selected message type includes :line. # @param [String] line The line to be processed. - # @param [Array<Symbol>] selected_messages A list of message types to check. + # @param [Array<Symbol>] selected_types A list of message types to check. # @param [Proc] block The block to be called with the line data. - def yield_line_if_selected(line, selected_messages, &block) - return unless block && selected_messages.include?(:line) + def yield_line_if_selected(line, selected_types, &block) + return unless block && block_type_selected?(selected_types, :line) block.call(:line, MarkdownExec::FCB.new(body: [line])) end end @@ -478,11 +533,12 @@ @@printed_messages.add(str) end end class HashDelegatorParent - attr_accessor :most_recent_loaded_filename, :pass_args, :run_state + attr_accessor :most_recent_loaded_filename, :pass_args, :run_state, + :p_all_arguments, :p_options_parsed, :p_params, :p_rest extend HashDelegatorSelf include CompactionHelpers include TextAnalyzer @@ -499,10 +555,15 @@ @link_history = LinkHistory.new @fout = FOut.new(@delegate_object) ### slice only relevant keys @process_mutex = Mutex.new @process_cv = ConditionVariable.new + + @p_all_arguments = [] + @p_options_parsed = [] + @p_params = {} + @p_rest = [] end # private # def [](key) @@ -511,10 +572,30 @@ # def []=(key, value) # @delegate_object[key] = value # end + ## + # Returns the absolute path of the given file path. + # If the provided path is already absolute, it returns it as is. + # Otherwise, it prefixes the path with the current working directory. + # + # @param file_path [String] The file path to process + # @return [String] The absolute path + # + # Example usage: + # absolute_path('/absolute/path/to/file.txt') # => '/absolute/path/to/file.txt' + # absolute_path('relative/path/to/file.txt') # => '/current/working/directory/relative/path/to/file.txt' + # + def absolute_path(file_path) + if File.absolute_path?(file_path) + file_path + else + File.join(Dir.getwd, file_path) + end + end + # Modifies the provided menu blocks array by adding 'Back' and 'Exit' options, # along with initial and final dividers, based on the delegate object's configuration. # # @param menu_blocks [Array] The array of menu block elements to be modified. def add_menu_chrome_blocks!(menu_blocks:, link_state:) @@ -531,24 +612,20 @@ # exit after other options if @delegate_object[:menu_with_exit] add_exit_option(menu_blocks: menu_blocks) end - add_dividers(menu_blocks: menu_blocks) + append_divider(menu_blocks: menu_blocks, position: :initial) + append_divider(menu_blocks: menu_blocks, position: :final) end private def add_back_option(menu_blocks:) append_chrome_block(menu_blocks: menu_blocks, menu_state: MenuState::BACK) end - def add_dividers(menu_blocks:) - append_divider(menu_blocks: menu_blocks, position: :initial) - append_divider(menu_blocks: menu_blocks, position: :final) - end - def add_exit_option(menu_blocks:) append_chrome_block(menu_blocks: menu_blocks, menu_state: MenuState::EXIT) end def add_inherited_lines(menu_blocks:, link_state:) @@ -597,10 +674,11 @@ chrome_block = FCB.new( chrome: true, dname: HashDelegator.new(@delegate_object).string_send_color( formatted_name, :menu_chrome_color ), + nickname: formatted_name, oname: formatted_name ) if insert_at_top menu_blocks.unshift(chrome_block) @@ -726,10 +804,13 @@ def blocks_find_by_block_name(blocks, block_name) @dml_blocks_in_file.find do |item| # 2024-08-04 match oname for long block names # 2024-08-04 match nickname for long block names block_name == item.pub_name || block_name == item.nickname || block_name == item.oname + end || @dml_menu_blocks.find do |item| + # 2024-08-22 search in menu blocks to allow matching of automatic chrome with nickname + block_name == item.pub_name || block_name == item.nickname || block_name == item.oname end end # private @@ -812,11 +893,11 @@ warn format_and_highlight_dependencies(dependencies, highlight: required[:unmet_dependencies]) runtime_exception(:runtime_exception_error_level, 'unmet_dependencies, flag: runtime_exception_error_level', required[:unmet_dependencies]) - elsif false ### use option 2024-08-02 + elsif @delegate_object[:dump_dependencies] warn format_and_highlight_dependencies(dependencies, highlight: [@delegate_object[:block_name]]) end if selected[:shell] == BlockType::OPTS @@ -896,31 +977,34 @@ # # @param mdoc [Object] The markdown document object containing code blocks. # @param selected [Hash] The selected item from the menu to be executed. # @return [LoadFileLinkState] An object indicating whether to load the next block or reuse the current one. def compile_execute_and_trigger_reuse(mdoc:, selected:, block_source:, - link_state: nil) - required_lines = collect_required_code_lines(mdoc: mdoc, selected: selected, link_state: link_state, - block_source: block_source) + link_state:) + required_lines = collect_required_code_lines( + mdoc: mdoc, selected: selected, + link_state: link_state, block_source: block_source + ) output_or_approval = @delegate_object[:output_script] || @delegate_object[:user_must_approve] if output_or_approval display_required_code(required_lines: required_lines) end allow_execution = if @delegate_object[:user_must_approve] - prompt_for_user_approval(required_lines: required_lines, - selected: selected) + prompt_for_user_approval( + required_lines: required_lines, + selected: selected + ) else true end if allow_execution execute_required_lines(required_lines: required_lines, selected: selected) end link_state.block_name = nil - LoadFileLinkState.new(LoadFile::REUSE, link_state) end # Check if the expression contains wildcard characters def contains_wildcards?(expr) expr.match(%r{\*|\?|\[}) @@ -1134,352 +1218,43 @@ def divider_formatting_present?(position) divider_key = position == :initial ? :menu_initial_divider : :menu_final_divider @delegate_object[:menu_divider_format].present? && @delegate_object[divider_key].present? end - def do_save_execution_output - return unless @delegate_object[:save_execution_output] - return if @run_state.in_own_window + def dml_menu_append_chrome_item( + name, count, type, menu_state: MenuState::LOAD, + always_create: true, always_enable: true + ) + raise unless name.present? + raise if @dml_menu_blocks.nil? - @run_state.files.write_execution_output_to_file(@delegate_object[:logged_stdout_filespec]) - end + item = @dml_menu_blocks.find { |block| block.oname == name } - # Select and execute a code block from a Markdown document. - # - # 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 document_inpseq - @menu_base_options = @delegate_object - @dml_link_state = LinkState.new( - block_name: @delegate_object[:block_name], - document_filename: @delegate_object[:filename] - ) - @run_state.source.block_name_from_cli = @dml_link_state.block_name.present? - @cli_block_name = @dml_link_state.block_name - @dml_now_using_cli = @run_state.source.block_name_from_cli - @dml_menu_default_dname = nil - @dml_block_state = SelectedBlockMenuState.new - @doc_saved_lines_files = [] + # create menu item when it is needed (count > 0) + # + if item.nil? && (always_create || count.positive?) + item = append_chrome_block(menu_blocks: @dml_menu_blocks, + menu_state: menu_state) + end - ## load file with code lines per options + # update item if it exists # - if @menu_base_options[:load_code].present? - @dml_link_state.inherited_lines = - @menu_base_options[:load_code].split(':').map do |path| - File.readlines(path, chomp: true) - end.flatten(1) + return unless item - inherited_block_names = [] - inherited_dependencies = {} - selected = FCB.new(oname: 'load_code') - pop_add_current_code_to_head_and_trigger_load(@dml_link_state, inherited_block_names, - code_lines, inherited_dependencies, selected) + item.dname = type.present? ? "#{name} (#{count} #{type})" : name + if always_enable || count.positive? + item.delete(:disabled) + else + item[:disabled] = '' end + end - fdo = ->(option) { - name = format(@delegate_object[:menu_link_format], - HashDelegator.safeval(@delegate_object[option])) - OpenStruct.new( - dname: name, - oname: name, - name: name, - pub_name: name.pub_name - ) - } - item_back = fdo.call(:menu_option_back_name) - item_edit = fdo.call(:menu_option_edit_name) - item_history = fdo.call(:menu_option_history_name) - item_load = fdo.call(:menu_option_load_name) - item_save = fdo.call(:menu_option_save_name) - item_shell = fdo.call(:menu_option_shell_name) - item_view = fdo.call(:menu_option_view_name) + def do_save_execution_output + return unless @delegate_object[:save_execution_output] + return if @run_state.in_own_window - @run_state.batch_random = Random.new.rand - @run_state.batch_index = 0 - - @run_state.files = StreamsOut.new - - InputSequencer.new( - @delegate_object[:filename], - @delegate_object[:input_cli_rest] - ).run do |msg, data| - # &bt msg - case msg - when :parse_document # once for each menu - # puts "@ - parse document #{data}" - inpseq_parse_document(data) - - if @delegate_object[:menu_for_history] - history_files(@dml_link_state).tap do |files| - if files.count.positive? - menu_enable_option(item_history.oname, files.count, 'files', - menu_state: MenuState::HISTORY) - end - end - end - - if @delegate_object[:menu_for_saved_lines] && @delegate_object[:document_saved_lines_glob].present? - - sf = document_name_in_glob_as_file_name( - @dml_link_state.document_filename, - @delegate_object[:document_saved_lines_glob] - ) - files = sf ? Dir.glob(sf) : [] - @doc_saved_lines_files = files.count.positive? ? files : [] - - lines_count = @dml_link_state.inherited_lines_count - - # add menu items (glob, load, save) and enable selectively - if files.count.positive? || lines_count.positive? - menu_add_disabled_option(sf) - end - if files.count.positive? - menu_enable_option(item_load.dname, files.count, 'files', - menu_state: MenuState::LOAD) - end - if lines_count.positive? - menu_enable_option(item_edit.dname, lines_count, 'lines', - menu_state: MenuState::EDIT) - end - if lines_count.positive? - menu_enable_option(item_save.dname, 1, '', - menu_state: MenuState::SAVE) - end - if lines_count.positive? - menu_enable_option(item_view.dname, 1, '', - menu_state: MenuState::VIEW) - end - if @delegate_object[:menu_with_shell] - menu_enable_option(item_shell.dname, 1, '', - menu_state: MenuState::SHELL) - end - - # # reflect new menu items - # @dml_mdoc = MDoc.new(@dml_menu_blocks) - end - - when :display_menu - # warn "@ - display menu:" - # ii_display_menu - @dml_block_state = SelectedBlockMenuState.new - @delegate_object[:block_name] = nil - - when :user_choice - if @dml_link_state.block_name.present? - # @prior_block_was_link = true - @dml_block_state.block = blocks_find_by_block_name(@dml_blocks_in_file, - @dml_link_state.block_name) - @dml_link_state.block_name = nil - else - # puts "? - Select a block to execute (or type #{$texit} to exit):" - break if inpseq_user_choice == :break # into @dml_block_state - break if @dml_block_state.block.nil? # no block matched - end - # puts "! - Executing block: #{data}" - @dml_block_state.block&.pub_name - - when :execute_block - case (block_name = data) - when item_back.pub_name - debounce_reset - @menu_user_clicked_back_link = true - load_file_link_state = pop_link_history_and_trigger_load - @dml_link_state = load_file_link_state.link_state - - InputSequencer.merge_link_state( - @dml_link_state, - InputSequencer.next_link_state( - block_name: @dml_link_state.block_name, - document_filename: @dml_link_state.document_filename, - prior_block_was_link: true - ) - ) - - when item_edit.pub_name - debounce_reset - edited = edit_text(@dml_link_state.inherited_lines_block) - @dml_link_state.inherited_lines = edited.split("\n") if edited - - return :break if pause_user_exit - - InputSequencer.next_link_state(prior_block_was_link: true) - - when item_history.pub_name - debounce_reset - files = history_files(@dml_link_state) - files_table_rows = files.map do |file| - if Regexp.new(@delegate_object[:saved_asset_match]) =~ file - begin - OpenStruct.new( - file: file, - row: format( - @delegate_object[:saved_history_format], - # create with default '*' so unknown parameters are given a wildcard - $~.names.each_with_object(Hash.new('*')) do |name, hash| - hash[name.to_sym] = $~[name] - end - ) - ) - rescue KeyError - # pp $!, $@ - warn "Cannot format with: #{@delegate_object[:saved_history_format]}" - error_handler('saved_history_format') - break - end - else - warn "Cannot parse name: #{file}" - next - end - end&.compact - - return :break unless files_table_rows - - # repeat select+display until user exits - row_attrib = :row - loop do - # menu with Back and Facet options at top - case (name = prompt_select_code_filename( - [@delegate_object[:prompt_filespec_back], - @delegate_object[:prompt_filespec_facet]] + - files_table_rows.map(&row_attrib), - string: @delegate_object[:prompt_select_history_file], - color_sym: :prompt_color_after_script_execution - )) - when @delegate_object[:prompt_filespec_back] - break - when @delegate_object[:prompt_filespec_facet] - row_attrib = row_attrib == :row ? :file : :row - else - file = files_table_rows.select { |ftr| ftr.row == name }&.first - info = file_info(file.file) - warn "#{file.file} - #{info[:lines]} lines / #{info[:size]} bytes" - warn(File.readlines(file.file, - chomp: false).map.with_index do |line, ind| - format(' %s. %s', format('% 4d', ind + 1).violet, line) - end) - end - end - - return :break if pause_user_exit - - InputSequencer.next_link_state(prior_block_was_link: true) - - when item_load.pub_name - debounce_reset - sf = document_name_in_glob_as_file_name(@dml_link_state.document_filename, - @delegate_object[:document_saved_lines_glob]) - load_filespec = load_filespec_from_expression(sf) - if load_filespec - @dml_link_state.inherited_lines_append( - File.readlines(load_filespec, chomp: true) - ) - end - - return :break if pause_user_exit - - InputSequencer.next_link_state(prior_block_was_link: true) - - when item_save.pub_name - debounce_reset - sf = document_name_in_glob_as_file_name(@dml_link_state.document_filename, - @delegate_object[:document_saved_lines_glob]) - save_filespec = save_filespec_from_expression(sf) - if save_filespec && !write_file_with_directory_creation( - save_filespec, - HashDelegator.join_code_lines(@dml_link_state.inherited_lines) - ) - return :break - - end - - InputSequencer.next_link_state(prior_block_was_link: true) - - when item_shell.pub_name - debounce_reset - loop do - command = prompt_for_command(":MDE #{Time.now.strftime('%FT%TZ')}> ".bgreen) - break if !command.present? || command == 'exit' - - exit_status = execute_command_with_streams( - [@delegate_object[:shell], '-c', command] - ) - case exit_status - when 0 - warn "#{'OK'.green} #{exit_status}" - else - warn "#{'ERR'.bred} #{exit_status}" - end - end - - return :break if pause_user_exit - - InputSequencer.next_link_state(prior_block_was_link: true) - - when item_view.pub_name - debounce_reset - warn @dml_link_state.inherited_lines_block - - return :break if pause_user_exit - - InputSequencer.next_link_state(prior_block_was_link: true) - - else - @dml_block_state = block_state_for_name_from_cli(block_name) - if @dml_block_state.block && @dml_block_state.block.shell == BlockType::OPTS - debounce_reset - link_state = LinkState.new - options_state = read_show_options_and_trigger_reuse( - link_state: link_state, - mdoc: @dml_mdoc, - selected: @dml_block_state.block - ) - - update_menu_base(options_state.options) - options_state.load_file_link_state.link_state - else - inpseq_execute_block(block_name) - - if prompt_user_exit(block_name_from_cli: @run_state.source.block_name_from_cli, - selected: @dml_block_state.block) - return :break - end - - ## order of block name processing: link block, cli, from user - # - @dml_link_state.block_name, @run_state.source.block_name_from_cli, cli_break = - HashDelegator.next_link_state( - block_name: @dml_link_state.block_name, - block_name_from_cli: @dml_now_using_cli, - block_state: @dml_block_state, - was_using_cli: @dml_now_using_cli - ) - - if !@dml_block_state.source.block_name_from_ui && cli_break - # &bsp '!block_name_from_ui + cli_break -> break' - return :break - end - - InputSequencer.next_link_state( - block_name: @dml_link_state.block_name, - prior_block_was_link: @dml_block_state.block.shell != BlockType::BASH - ) - end - end - - when :exit? - data == $texit - when :stay? - data == $stay - else - raise "Invalid message: #{msg}" - end - end - rescue StandardError - HashDelegator.error_handler('document_inpseq', - { abort: true }) + @run_state.files.write_execution_output_to_file(@delegate_object[:logged_stdout_filespec]) end # remove leading "./" # replace characters: / : . * (space) with: (underscore) def document_name_in_glob_as_file_name(document_filename, glob) @@ -1490,13 +1265,13 @@ format(glob, { document_filename: document_filename.gsub(%r{^\./}, '').gsub(/[\/:\.\* ]/, '_') }) end - def dump_and_warn_block_state(selected:) + def dump_and_warn_block_state(name:, selected:) if selected.nil? - Exceptions.warn_format("Block not found -- name: #{@delegate_object[:block_name]}", + Exceptions.warn_format("Block not found -- name: #{name}", { abort: true }) end return unless @delegate_object[:dump_selected_block] @@ -1589,24 +1364,43 @@ temp_file.unlink result_text end - def exec_bash_next_state(selected:, mdoc:, link_state:, block_source: {}) - lfls = execute_shell_type( + def execute_block_for_state_and_name(selected:, mdoc:, link_state:, + block_source: {}) + lfls = execute_block_by_type_for_lfls( selected: selected, mdoc: mdoc, link_state: link_state, block_source: block_source ) # if the same menu is being displayed, collect the display name of the selected menu item for use as the default item [lfls.link_state, - lfls.load_file == LoadFile::LOAD ? nil : selected.dname] - #.tap { |ret| pp [__FILE__,__LINE__,'exec_bash_next_state()',ret] } + lfls.load_file == LoadFile::LOAD ? nil : selected.dname, + # 2024-08-22 true to quit + lfls.load_file == LoadFile::EXIT] end + def execute_block_in_state(block_name) + @dml_block_state = block_state_for_name_from_cli(block_name) + dump_and_warn_block_state(name: block_name, + selected: @dml_block_state.block) + @dml_link_state, @dml_menu_default_dname, quit = + execute_block_for_state_and_name( + selected: @dml_block_state.block, + mdoc: @dml_mdoc, + link_state: @dml_link_state, + block_source: { + document_filename: @delegate_object[:filename], + time_now_date: Time.now.utc.strftime(@delegate_object[:shell_code_label_time_format]) + } + ) + :break if quit + end + # Executes a given command and processes its input, output, and error streams. # # @param [Array<String>] command the command to execute along with its arguments. # @yield [stdin, stdout, stderr, thread] if a block is provided, it yields input, output, error lines, and the execution thread. # @return [Integer] the exit status of the executed command (0 to 255). @@ -1653,10 +1447,86 @@ end exit_status end + def execute_history_select( + files_table_rows, + exit_prompt: @delegate_object[:prompt_filespec_back], + pause_refresh: false, + stream: + ) + # repeat select+display until user exits + + pause_now = false + row_attrib = :row + loop do + if pause_now + break if prompt_select_continue == MenuState::EXIT + end + + # menu with Back and Facet options at top + case (name = prompt_select_code_filename( + [exit_prompt, + @delegate_object[:prompt_filespec_facet]] + + files_table_rows.map(&row_attrib), + string: @delegate_object[:prompt_select_history_file], + color_sym: :prompt_color_after_script_execution + )) + when exit_prompt + break + when @delegate_object[:prompt_filespec_facet] + row_attrib = row_attrib == :row ? :file : :row + pause_now = false + else + file = files_table_rows.select { |ftr| ftr.row == name }&.first + info = file_info(file.file) + stream.puts "#{file.file} - #{info[:lines]} lines / " \ + "#{info[:size]} bytes" + stream.puts( + File.readlines(file.file, + chomp: false).map.with_index do |line, ind| + format(' %s. %s', + AnsiString.new(format('% 4d', ind + 1)).send(:violet), line) + end + ) + pause_now = pause_refresh + end + end + end + + def execute_inherited_save + save_filespec = save_filespec_from_expression( + document_name_in_glob_as_file_name( + @dml_link_state.document_filename, + @delegate_object[:document_saved_lines_glob] + ) + ) + if save_filespec && !write_file_with_directory_creation( + save_filespec, + HashDelegator.join_code_lines(@dml_link_state.inherited_lines) + ) + :break + end + end + + def execute_navigate_back + @menu_user_clicked_back_link = true + + keep_code = @dml_link_state.keep_code + inherited_lines = keep_code ? @dml_link_state.inherited_lines_block : nil + + @dml_link_state = pop_link_history_new_state + + { + block_name: @dml_link_state.block_name, + document_filename: @dml_link_state.document_filename, + inherited_lines: inherited_lines, + keep_code: keep_code + } + end + # Executes a block of code that has been approved for execution. # 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. @@ -1680,23 +1550,32 @@ # code to the clipboard or save it to a file. # # @param opts [Hash] Options hash containing configuration settings. # @param mdoc [YourMDocClass] An instance of the MDoc class. # - def execute_shell_type(selected:, mdoc:, block_source:, - link_state: LinkState.new) + def execute_block_by_type_for_lfls(selected:, mdoc:, block_source:, + link_state: LinkState.new) if selected.shell == BlockType::LINK debounce_reset push_link_history_and_trigger_load(link_block_body: selected.body, mdoc: mdoc, selected: selected, link_state: link_state, block_source: block_source) + # from CLI + elsif selected.nickname == @delegate_object[:menu_option_exit_name][:line] + debounce_reset + LoadFileLinkState.new(LoadFile::EXIT, link_state) + elsif @menu_user_clicked_back_link debounce_reset - pop_link_history_and_trigger_load + # pop_link_history_new_state + LoadFileLinkState.new( + LoadFile::LOAD, + pop_link_history_new_state + ) elsif selected.shell == BlockType::OPTS debounce_reset code_lines = [] options_state = read_show_options_and_trigger_reuse( @@ -1728,10 +1607,11 @@ elsif debounce_allows compile_execute_and_trigger_reuse(mdoc: mdoc, selected: selected, link_state: link_state, block_source: block_source) + LoadFileLinkState.new(LoadFile::REUSE, link_state) else LoadFileLinkState.new(LoadFile::REUSE, link_state) end end @@ -1810,10 +1690,31 @@ # Expand expression if it contains format specifiers def formatted_expression(expr) expr.include?('%{') ? format_expression(expr) : expr end + def fout_execution_report + @fout.fout fetch_color(data_sym: :execution_report_preview_head, + color_sym: :execution_report_preview_frame_color) + [ + ['Block', @run_state.script_block_name], + ['Command', ([MarkdownExec::BIN_NAME, @delegate_object[:filename]] + + (@run_state.link_history.map { |item| + item[:block_name] + }) + + [@run_state.script_block_name]).join(' ')], + ['Script', @run_state.saved_filespec], + ['StdOut', @delegate_object[:logged_stdout_filespec]] + ].each do |label, value| + next unless value + + output_labeled_value(label, value, DISPLAY_LEVEL_ADMIN) + end + @fout.fout fetch_color(data_sym: :execution_report_preview_tail, + color_sym: :execution_report_preview_frame_color) + end + def generate_temp_filename(ext = '.sh') filename = begin Dir::Tmpname.make_tmpname(['x', ext], nil) rescue NoMethodError require 'securerandom' @@ -1927,61 +1828,22 @@ in_fenced_block: false, headings: [] } end - def inpseq_execute_block(block_name) - @dml_block_state = block_state_for_name_from_cli(block_name) - dump_and_warn_block_state(selected: @dml_block_state.block) - @dml_link_state, @dml_menu_default_dname = - exec_bash_next_state( - selected: @dml_block_state.block, - mdoc: @dml_mdoc, - link_state: @dml_link_state, - block_source: { - document_filename: @delegate_object[:filename], - time_now_date: Time.now.utc.strftime(@delegate_object[:shell_code_label_time_format]) - } - ) - end - - def inpseq_parse_document(_document_filename) - @run_state.batch_index += 1 - @run_state.in_own_window = false - - # &bsp 'loop', block_name_from_cli, @cli_block_name - @run_state.source.block_name_from_cli, @dml_now_using_cli, @dml_blocks_in_file, @dml_menu_blocks, @dml_mdoc = - set_delobj_menu_loop_vars(block_name_from_cli: @run_state.source.block_name_from_cli, - now_using_cli: @dml_now_using_cli, - link_state: @dml_link_state) - end - - def inpseq_user_choice - @dml_block_state = load_cli_or_user_selected_block(all_blocks: @dml_blocks_in_file, - menu_blocks: @dml_menu_blocks, - default: @dml_menu_default_dname) - # &bsp '@run_state.source.block_name_from_cli:',@run_state.source.block_name_from_cli - if !@dml_block_state - HashDelegator.error_handler('block_state missing', { abort: true }) - elsif @dml_block_state.state == MenuState::EXIT - # &bsp 'load_cli_or_user_selected_block -> break' - :break - end - end - # Iterates through blocks in a file, applying the provided block to each line. # The iteration only occurs if the file exists. # @yield [Symbol] :filter Yields to obtain selected messages for processing. def iter_blocks_from_nested_files(&block) return unless check_file_existence(@delegate_object[:filename]) state = initial_state - selected_messages = yield :filter + selected_types = yield :filter cfile.readlines(@delegate_object[:filename], import_paths: @delegate_object[:import_paths]&.split(':')).each do |nested_line| if nested_line - update_line_and_block_state(nested_line, state, selected_messages, + update_line_and_block_state(nested_line, state, selected_types, &block) end end end @@ -2025,47 +1887,53 @@ end label_format_above = @delegate_object[:shell_code_label_format_above] label_format_below = @delegate_object[:shell_code_label_format_below] - [label_format_above && format(label_format_above, - block_source.merge({ block_name: selected.pub_name }))] + + [label_format_above.present? && + format(label_format_above, + block_source.merge({ block_name: selected.pub_name }))] + output_lines.map do |line| re = Regexp.new(link_block_data.fetch('pattern', '(?<line>.*)')) next unless re =~ line re.gsub_format(line, link_block_data.fetch('format', '%{line}')) end.compact + - [label_format_below && format(label_format_below, - block_source.merge({ block_name: selected.pub_name }))] + [label_format_below.present? && + format(label_format_below, + block_source.merge({ block_name: selected.pub_name }))] end def link_history_push_and_next( curr_block_name:, curr_document_filename:, inherited_block_names:, inherited_dependencies:, inherited_lines:, + keep_code:, next_block_name:, next_document_filename:, + next_keep_code:, next_load_file: ) @link_history.push( LinkState.new( block_name: curr_block_name, document_filename: curr_document_filename, inherited_block_names: inherited_block_names, inherited_dependencies: inherited_dependencies, - inherited_lines: inherited_lines + inherited_lines: inherited_lines, + keep_code: keep_code ) ) LoadFileLinkState.new( next_load_file, LinkState.new( block_name: next_block_name, document_filename: next_document_filename, inherited_block_names: inherited_block_names, inherited_dependencies: inherited_dependencies, - inherited_lines: inherited_lines + inherited_lines: inherited_lines, + keep_code: next_keep_code ) ) end def link_load_format_data @@ -2117,12 +1985,10 @@ source = OpenStruct.new(block_name_from_ui: true) state = block_state.state end SelectedBlockMenuState.new(block, source, state) - rescue StandardError - HashDelegator.error_handler('load_cli_or_user_selected_block') end # format + glob + select for file in load block # name has references to ENV vars and doc and batch vars incl. timestamp def load_filespec_from_expression(expression) @@ -2159,10 +2025,27 @@ name end end end + def manage_cli_selection_state(block_name_from_cli:, now_using_cli:, + link_state:) + if block_name_from_cli && @cli_block_name == @menu_base_options[:menu_persist_block_name] + # &bsp 'pause cli control, allow user to select block' + block_name_from_cli = false + now_using_cli = false + @menu_base_options[:block_name] = + @delegate_object[:block_name] = \ + link_state.block_name = + @cli_block_name = nil + end + + @delegate_object = @menu_base_options.dup + @menu_user_clicked_back_link = false + [block_name_from_cli, now_using_cli] + end + def mdoc_and_blocks_from_nested_files menu_blocks = blocks_from_nested_files mdoc = MDoc.new(menu_blocks) do |nopts| @delegate_object.merge!(nopts) end @@ -2182,10 +2065,11 @@ menu_blocks = mdoc.fcbs_per_options(@delegate_object) add_menu_chrome_blocks!(menu_blocks: menu_blocks, link_state: link_state) ### compress empty lines HashDelegator.delete_consecutive_blank_lines!(menu_blocks) + HashDelegator.tables_into_columns!(menu_blocks, @delegate_object) [all_blocks, menu_blocks, mdoc] # &br end def menu_add_disabled_option(name) raise unless name.present? @@ -2238,52 +2122,10 @@ else option_value end end - def menu_enable_option(name, count, type, menu_state: MenuState::LOAD) - raise unless name.present? - raise if @dml_menu_blocks.nil? - - item = @dml_menu_blocks.find { |block| block.oname == name } - - # create menu item when it is needed (count > 0) - # - if item.nil? && count.positive? - item = append_chrome_block(menu_blocks: @dml_menu_blocks, - menu_state: menu_state) - end - - # update item if it exists - # - return unless item - - item.dname = type.present? ? "#{name} (#{count} #{type})" : name - if count.positive? - item.delete(:disabled) - else - item[:disabled] = '' - end - end - - def manage_cli_selection_state(block_name_from_cli:, now_using_cli:, - link_state:) - if block_name_from_cli && @cli_block_name == @menu_base_options[:menu_persist_block_name] - # &bsp 'pause cli control, allow user to select block' - block_name_from_cli = false - now_using_cli = false - @menu_base_options[:block_name] = - @delegate_object[:block_name] = \ - link_state.block_name = - @cli_block_name = nil - end - - @delegate_object = @menu_base_options.dup - @menu_user_clicked_back_link = false - [block_name_from_cli, now_using_cli] - end - # If a method is missing, treat it as a key for the @delegate_object. def method_missing(method_name, *args, &block) if @delegate_object.respond_to?(method_name) @delegate_object.send(method_name, *args, &block) elsif method_name.to_s.end_with?('=') && args.size == 1 @@ -2307,43 +2149,24 @@ curr_block_name: selected.pub_name, curr_document_filename: @delegate_object[:filename], inherited_block_names: ((link_state&.inherited_block_names || []) + block_names).sort.uniq, inherited_dependencies: (link_state&.inherited_dependencies || {}).merge(dependencies || {}), ### merge, not replace, key data inherited_lines: HashDelegator.code_merge(code_lines), + keep_code: link_state&.keep_code, next_block_name: '', next_document_filename: @delegate_object[:filename], + next_keep_code: false, next_load_file: LoadFile::REUSE ) end def output_color_formatted(data_sym, color_sym) formatted_string = string_send_color(@delegate_object[data_sym], color_sym) @fout.fout formatted_string end - def fout_execution_report - @fout.fout fetch_color(data_sym: :execution_report_preview_head, - color_sym: :execution_report_preview_frame_color) - [ - ['Block', @run_state.script_block_name], - ['Command', ([MarkdownExec::BIN_NAME, @delegate_object[:filename]] + - (@run_state.link_history.map { |item| - item[:block_name] - }) + - [@run_state.script_block_name]).join(' ')], - ['Script', @run_state.saved_filespec], - ['StdOut', @delegate_object[:logged_stdout_filespec]] - ].each do |label, value| - next unless value - - output_labeled_value(label, value, DISPLAY_LEVEL_ADMIN) - end - @fout.fout fetch_color(data_sym: :execution_report_preview_tail, - color_sym: :execution_report_preview_frame_color) - end - def output_execution_summary return unless @delegate_object[:output_execution_summary] @fout.fout_section 'summary', { execute_aborted_at: @run_state.aborted_at, @@ -2399,33 +2222,32 @@ ((link_state&.inherited_block_names || []) + block_names).sort.uniq, inherited_dependencies: (link_state&.inherited_dependencies || {}).merge(dependencies || {}), ### merge, not replace, key data inherited_lines: HashDelegator.code_merge(link_state&.inherited_lines, code_lines), + keep_code: link_state&.keep_code, next_block_name: next_block_name, next_document_filename: @delegate_object[:filename], # not next_document_filename + next_keep_code: false, next_load_file: LoadFile::REUSE # not next_document_filename == @delegate_object[:filename] ? LoadFile::REUSE : LoadFile::LOAD ) # LoadFileLinkState.new(LoadFile::REUSE, link_state) end end # This method handles the back-link operation in the Markdown execution context. - # It updates the history state and prepares to load the next block. + # It updates the history state for the next block. # - # @return [LoadFileLinkState] An object indicating the action to load the next block. - def pop_link_history_and_trigger_load + # @return [LinkState] An object indicating the state for the next block. + def pop_link_history_new_state pop = @link_history.pop peek = @link_history.peek - LoadFileLinkState.new( - LoadFile::LOAD, - LinkState.new( - document_filename: pop.document_filename, - inherited_block_names: peek.inherited_block_names, - inherited_dependencies: peek.inherited_dependencies, - inherited_lines: peek.inherited_lines - ) + LinkState.new( + document_filename: pop.document_filename, + inherited_block_names: peek.inherited_block_names, + inherited_dependencies: peek.inherited_dependencies, + inherited_lines: peek.inherited_lines ) end def post_execution_process do_save_execution_output @@ -2530,12 +2352,10 @@ return false if sel == @delegate_object[:prompt_no] return true if sel == @delegate_object[:prompt_yes] @allowed_execution_block = @prior_execution_block true - rescue TTY::Reader::InputInterrupt - exit 1 end def prompt_for_command(prompt) print prompt @@ -2600,12 +2420,10 @@ elsif sel == MenuOptions::SAVE_SCRIPT save_to_file(required_lines: required_lines, selected: selected) end sel == MenuOptions::YES - rescue TTY::Reader::InputInterrupt - exit 1 end # public def prompt_select_code_filename( @@ -2630,12 +2448,10 @@ else menu.choice filename end end end - rescue TTY::Reader::InputInterrupt - exit 1 end def prompt_select_continue(filter: true, quiet: true) sel = @prompt.select( string_send_color(@delegate_object[:prompt_after_script_execution], @@ -2645,12 +2461,10 @@ ) do |menu| menu.choice @delegate_object[:prompt_yes] menu.choice @delegate_object[:prompt_exit] end sel == @delegate_object[:prompt_exit] ? MenuState::EXIT : MenuState::CONTINUE - rescue TTY::Reader::InputInterrupt - exit 1 end # user prompt to exit if the menu will be displayed again # def prompt_user_exit(block_name_from_cli:, selected:) @@ -2727,20 +2541,23 @@ if link_block_data[LinkKeys::RETURN] pop_add_current_code_to_head_and_trigger_load(link_state, block_names, code_lines, dependencies, selected, next_block_name: next_block_name) else + next_keep_code = link_state&.keep_code || link_block_data.fetch('keep', false) #/*LinkKeys::KEEP*/ link_history_push_and_next( curr_block_name: selected.pub_name, curr_document_filename: @delegate_object[:filename], inherited_block_names: ((link_state&.inherited_block_names || []) + block_names).sort.uniq, inherited_dependencies: (link_state&.inherited_dependencies || {}).merge(dependencies || {}), ### merge, not replace, key data inherited_lines: HashDelegator.code_merge( link_state&.inherited_lines, code_lines ), + keep_code: link_state&.keep_code, next_block_name: next_block_name, next_document_filename: next_document_filename, + next_keep_code: next_keep_code, next_load_file: next_document_filename == @delegate_object[:filename] ? LoadFile::REUSE : LoadFile::LOAD ) end end @@ -2751,25 +2568,53 @@ { expr: filespec }) puts @delegate_object[:prompt_enter_filespec] gets.chomp end + def read_saved_assets_for_history_table + files = history_files(@dml_link_state).sort + files.map do |file| + if Regexp.new(@delegate_object[:saved_asset_match]) =~ file + begin + OpenStruct.new( + file: file, + row: format( + @delegate_object[:saved_history_format], + # create with default '*' so unknown parameters are given a wildcard + $~.names.each_with_object(Hash.new('*')) do |name, hash| + hash[name.to_sym] = $~[name] + end + ) + ) + rescue KeyError + # pp $!, $@ + warn "Cannot format with: #{@delegate_object[:saved_history_format]}" + error_handler('saved_history_format') + return nil + end + else + warn "Cannot parse name: #{file}" + next + end + end&.compact + end + # Processes YAML data from the selected menu item, updating delegate objects and optionally printing formatted output. # @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 [LoadFileLinkState] An instance indicating the next action for loading files. def read_show_options_and_trigger_reuse(selected:, mdoc:, link_state: LinkState.new) obj = {} # concatenated body of all required blocks loaded a YAML - data = YAML.load( + data = (YAML.load( collect_required_code_lines( mdoc: mdoc, selected: selected, link_state: link_state, block_source: {} ).join("\n") - ).transform_keys(&:to_sym) + ) || {}).transform_keys(&:to_sym) if selected.shell == BlockType::OPTS obj = data else (data || []).each do |key, value| @@ -2845,18 +2690,23 @@ 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)) + AnsiString.new(format( + @delegate_object.fetch(:exception_format_name, + "\n%{name}"), + data + )).send(@delegate_object.fetch(:exception_color_name, + :red)) + + AnsiString.new(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] @@ -2905,10 +2755,28 @@ def save_to_file(required_lines:, selected:) write_command_file(required_lines: required_lines, selected: selected) @fout.fout "File saved: #{@run_state.saved_filespec}" end + def select_document_if_multiple(options, files, prompt:) + # binding.irb + return files if files.class == String ### + return files[0] if (count = files.count) == 1 + + return unless count >= 2 + + opts = options.dup + select_option_or_exit( + string_send_color( + prompt, + :prompt_color_after_script_execution + ), + files, + opts.merge(per_page: opts[:select_page_height]) + ) + end + # Presents a TTY prompt to select an option or exit, returns metadata including option and selected def select_option_with_metadata(prompt_text, menu_items, opts = {}) ## configure to environment # register_console_attributes(opts) @@ -2946,46 +2814,12 @@ else selected.selected = selection end selected - rescue TTY::Reader::InputInterrupt - exit 1 - rescue StandardError - HashDelegator.error_handler('select_option_with_metadata') end - # Update the block name in the link state and delegate object. - # - # This method updates the block name based on whether it was specified - # through the CLI or derived from the link state. - # - # @param link_state [LinkState] The current link state object. - # @param block_name_from_cli [Boolean] Indicates if the block name is from CLI. - def set_delob_filename_block_name(link_state:, block_name_from_cli:) - @delegate_object[:filename] = link_state.document_filename - link_state.block_name = @delegate_object[:block_name] = - block_name_from_cli ? @cli_block_name : link_state.block_name - end - - def set_delobj_menu_loop_vars(block_name_from_cli:, now_using_cli:, - link_state:) - block_name_from_cli, now_using_cli = - manage_cli_selection_state(block_name_from_cli: block_name_from_cli, - now_using_cli: now_using_cli, - link_state: link_state) - set_delob_filename_block_name(link_state: link_state, - block_name_from_cli: block_name_from_cli) - - # update @delegate_object and @menu_base_options in auto_load - # - blocks_in_file, menu_blocks, mdoc = mdoc_menu_and_blocks_from_nested_files(link_state) - dump_delobj(blocks_in_file, menu_blocks, link_state) - - [block_name_from_cli, now_using_cli, blocks_in_file, menu_blocks, mdoc] - end - def set_environment_variables_for_block(selected) code_lines = [] YAML.load(selected.body.join("\n"))&.each do |key, value| ENV[key] = value.to_s @@ -3066,11 +2900,11 @@ reqs: reqs, shell: fcb_title_groups.fetch(:shell, ''), stdin: if (tn = rest.match(/<(?<type>\$)?(?<name>[A-Za-z_-]\S+)/)) tn.named_captures.sym_keys end, - stdout: if (tn = rest.match(/>(?<type>\$)?(?<name>[A-Za-z_\-.\w]+)/)) + stdout: if (tn = rest.match(/>(?<type>\$)?(?<name>[\w.\-]+)/)) tn.named_captures.sym_keys end, title: title, wraps: wraps ) @@ -3091,34 +2925,34 @@ # This function is designed to be called within a loop that iterates through each line of a document. # # @param line [String] The current line being processed. # @param state [Hash] The current state of the parser, including flags and data related to the processing. # @param opts [Hash] A hash containing various options for line and block processing. - # @param selected_messages [Array<String>] Accumulator for lines or messages that are subject to further processing. + # @param selected_types [Array<String>] Accumulator for lines or messages that are subject to further processing. # @param block [Proc] An optional block for further processing or transformation of lines. # # @option state [Array<String>] :headings Current headings to be updated based on the line. # @option state [Regexp] :fenced_start_and_end_regex Regular expression to match the start and end of a fenced block. # @option state [Boolean] :in_fenced_block Flag indicating whether the current line is inside a fenced block. # @option state [Object] :fcb An object representing the current fenced code block being processed. # # @option opts [Boolean] :menu_blocks_with_headings Flag indicating whether to update headings while processing. # - # @return [Void] The function modifies the `state` and `selected_messages` arguments in place. + # @return [Void] The function modifies the `state` and `selected_types` arguments in place. ## - def update_line_and_block_state(nested_line, state, selected_messages, + def update_line_and_block_state(nested_line, state, selected_types, &block) line = nested_line.to_s if line.match(@delegate_object[:fenced_start_and_end_regex]) if state[:in_fenced_block] ## end of code block # HashDelegator.update_menu_attrib_yield_selected( fcb: state[:fcb], - messages: selected_messages, + messages: selected_types, configuration: @delegate_object, - &block + &block ) state[:in_fenced_block] = false else ## start of code block # @@ -3136,25 +2970,479 @@ line.chomp.sub(/^#{state[:fcb].indent}/, '') ] elsif nested_line[:depth].zero? || @delegate_object[:menu_include_imported_notes] # add line if it is depth 0 or option allows it # - HashDelegator.yield_line_if_selected(line, selected_messages, &block) + HashDelegator.yield_line_if_selected(line, selected_types, &block) else # &bsp 'line is not recognized for block state' end end ## apply options to current state # def update_menu_base(options) - @menu_base_options.merge!(options) + # under simple uses, @menu_base_options may be nil + @menu_base_options&.merge!(options) @delegate_object.merge!(options) end + def vux_await_user_selection + @dml_block_state = load_cli_or_user_selected_block(all_blocks: @dml_blocks_in_file, + menu_blocks: @dml_menu_blocks, + default: @dml_menu_default_dname) + # &bsp '@run_state.source.block_name_from_cli:',@run_state.source.block_name_from_cli + if !@dml_block_state + HashDelegator.error_handler('block_state missing', { abort: true }) + elsif @dml_block_state.state == MenuState::EXIT + # &bsp 'load_cli_or_user_selected_block -> break' + :break + end + end + + def vux_clear_menu_state + @dml_block_state = SelectedBlockMenuState.new + @delegate_object[:block_name] = nil + end + + def vux_edit_inherited + edited = edit_text(@dml_link_state.inherited_lines_block) + @dml_link_state.inherited_lines = edited.split("\n") if edited + end + + def vux_execute_and_prompt(block_name) + @dml_block_state = block_state_for_name_from_cli(block_name) + if @dml_block_state.block && @dml_block_state.block.shell == BlockType::OPTS + debounce_reset + link_state = LinkState.new + options_state = read_show_options_and_trigger_reuse( + link_state: link_state, + mdoc: @dml_mdoc, + selected: @dml_block_state.block + ) + + update_menu_base(options_state.options) + options_state.load_file_link_state.link_state + return + end + + return :break if execute_block_in_state(block_name) == :break + + if prompt_user_exit(block_name_from_cli: @run_state.source.block_name_from_cli, + selected: @dml_block_state.block) + return :break + end + + ## order of block name processing: link block, cli, from user + # + @dml_link_state.block_name, @run_state.source.block_name_from_cli, cli_break = + HashDelegator.next_link_state( + block_name: @dml_link_state.block_name, + block_name_from_cli: @dml_now_using_cli, + block_state: @dml_block_state, + was_using_cli: @dml_now_using_cli + ) + + # &bsp '!block_name_from_ui + cli_break -> break' + !@dml_block_state.source.block_name_from_ui && cli_break && :break + end + + def vux_execute_block_per_type(block_name, formatted_choice_ostructs) + case block_name + when formatted_choice_ostructs[:back].pub_name + debounce_reset + vux_navigate_back_for_ls + + when formatted_choice_ostructs[:edit].pub_name + debounce_reset + vux_edit_inherited + return :break if pause_user_exit + + InputSequencer.next_link_state(prior_block_was_link: true) + + when formatted_choice_ostructs[:history].pub_name + debounce_reset + files_table_rows = read_saved_assets_for_history_table + return :break unless files_table_rows + + execute_history_select(files_table_rows, stream: $stderr) + return :break if pause_user_exit + + InputSequencer.next_link_state(prior_block_was_link: true) + + when formatted_choice_ostructs[:load].pub_name + debounce_reset + vux_load_inherited + return :break if pause_user_exit + + InputSequencer.next_link_state(prior_block_was_link: true) + + when formatted_choice_ostructs[:save].pub_name + debounce_reset + return :break if execute_inherited_save == :break + + InputSequencer.next_link_state(prior_block_was_link: true) + + when formatted_choice_ostructs[:shell].pub_name + debounce_reset + vux_input_and_execute_shell_commands(stream: $stderr) + return :break if pause_user_exit + + InputSequencer.next_link_state(prior_block_was_link: true) + + when formatted_choice_ostructs[:view].pub_name + debounce_reset + vux_view_inherited(stream: $stderr) + return :break if pause_user_exit + + InputSequencer.next_link_state(prior_block_was_link: true) + + else + return :break if vux_execute_and_prompt(block_name) == :break + + InputSequencer.next_link_state( + block_name: @dml_link_state.block_name, + prior_block_was_link: @dml_block_state.block.shell != BlockType::BASH + ) + end + end + + def vux_formatted_names_for_state_chrome_blocks( + names: %w[back edit history load save shell view] + ) + names.each_with_object({}) do |name, result| + do_key = :"menu_option_#{name}_name" + oname = HashDelegator.safeval(@delegate_object[do_key]) + dname = format(@delegate_object[:menu_link_format], oname) + result[name.to_sym] = OpenStruct.new( + dname: dname, + name: dname, + oname: dname, + pub_name: dname.pub_name + ) + end + end + + def vux_init + @menu_base_options = @delegate_object + @dml_link_state = LinkState.new( + block_name: @delegate_object[:block_name], + document_filename: @delegate_object[:filename] + ) + @run_state.source.block_name_from_cli = @dml_link_state.block_name.present? + @cli_block_name = @dml_link_state.block_name + @dml_now_using_cli = @run_state.source.block_name_from_cli + @dml_menu_default_dname = nil + @dml_block_state = SelectedBlockMenuState.new + @doc_saved_lines_files = [] + + @run_state.batch_random = Random.new.rand + @run_state.batch_index = 0 + + @run_state.files = StreamsOut.new + end + + def vux_input_and_execute_shell_commands(stream:) + loop do + command = prompt_for_command(AnsiString.new(":MDE #{Time.now.strftime('%FT%TZ')}> ").send(:bgreen)) + break if !command.present? || command == 'exit' + + exit_status = execute_command_with_streams( + [@delegate_object[:shell], '-c', command] + ) + case exit_status + when 0 + stream.puts "#{'OK'.green} #{exit_status}" + else + stream.puts "#{'ERR'.bred} #{exit_status}" + end + end + end + + ## load file with code lines per options + # + def vux_load_code_files_into_state + return unless @menu_base_options[:load_code].present? + + @dml_link_state.inherited_lines = + @menu_base_options[:load_code].split(':').map do |path| + File.readlines(path, chomp: true) + end.flatten(1) + + inherited_block_names = [] + inherited_dependencies = {} + selected = FCB.new(oname: 'load_code') + pop_add_current_code_to_head_and_trigger_load( + @dml_link_state, inherited_block_names, + code_lines, inherited_dependencies, selected + ) + end + + def vux_load_inherited + sf = document_name_in_glob_as_file_name(@dml_link_state.document_filename, + @delegate_object[:document_saved_lines_glob]) + load_filespec = load_filespec_from_expression(sf) + return unless load_filespec + + @dml_link_state.inherited_lines_append( + File.readlines(load_filespec, chomp: true) + ) + end + + # Select and execute a code block from a Markdown document. + # + # 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 vux_main_loop + vux_init + vux_load_code_files_into_state + formatted_choice_ostructs = vux_formatted_names_for_state_chrome_blocks + + block_list = [@delegate_object[:block_name]].select(&:present?).compact + @delegate_object[:input_cli_rest] + @delegate_object[:block_name] = nil + + process_commands( + arguments: @p_all_arguments, + named_procs: yield(:command_names, @delegate_object), + options_parsed: @p_options_parsed, + rest: @p_rest, + enable_search: @delegate_object[:default_find_select_open] + ) do |type, data| + case type + when ArgPro::ActSetBlockName + @delegate_object[:block_name] = data + @delegate_object[:input_cli_rest] = '' + when ArgPro::ConvertValue + # call for side effects, output, or exit + data[0].call(data[1]) + when ArgPro::ActFileIsMissing + raise FileMissingError, data, caller + when ArgPro::ActFind + find_value(data, execute_chosen_found: true) + when ArgPro::ActSetFileName + @delegate_object[:filename] = data + when ArgPro::ActSetPath + @delegate_object[:path] = data + when ArgPro::CallProcess + yield :call_proc, [@delegate_object, data] + when ArgPro::ActSetOption + @delegate_object[data[0]] = data[1] + else + raise + end + end + + InputSequencer.new( + @delegate_object[:filename], + block_list + ).run do |msg, data| + # &bt msg + case msg + when :parse_document # once for each menu + vux_parse_document + vux_menu_append_history_files(formatted_choice_ostructs) + vux_publish_document_file_name_for_external_automation + + when :display_menu + vux_clear_menu_state + + when :user_choice + vux_user_selected_block_name + + when :execute_block + ret = vux_execute_block_per_type(data, formatted_choice_ostructs) + vux_publish_block_name_for_external_automation(data) + ret + + when :close_ux + if @vux_pipe_open.present? && File.exist?(@vux_pipe_open) + @vux_pipe_open.close + @vux_pipe_open = nil + end + if @vux_pipe_created.present? && File.exist?(@vux_pipe_created) + File.delete(@vux_pipe_created) + @vux_pipe_created = nil + end + + when :exit? + data == $texit + + when :stay? + data == $stay + + else + raise "Invalid message: #{msg}" + + end + end + end + + def vux_menu_append_history_files(formatted_choice_ostructs) + if @delegate_object[:menu_for_history] + history_files(@dml_link_state).tap do |files| + if files.count.positive? + dml_menu_append_chrome_item( + formatted_choice_ostructs[:history].oname, files.count, + 'files', menu_state: MenuState::HISTORY + ) + end + end + end + + return unless @delegate_object[:menu_for_saved_lines] && @delegate_object[:document_saved_lines_glob].present? + + sf = document_name_in_glob_as_file_name( + @dml_link_state.document_filename, + @delegate_object[:document_saved_lines_glob] + ) + files = sf ? Dir.glob(sf) : [] + @doc_saved_lines_files = files.count.positive? ? files : [] + + lines_count = @dml_link_state.inherited_lines_count + + # add menu items (glob, load, save) and enable selectively + if files.count.positive? || lines_count.positive? + menu_add_disabled_option(sf) + end + if files.count.positive? + dml_menu_append_chrome_item(formatted_choice_ostructs[:load].dname, files.count, 'files', + menu_state: MenuState::LOAD) + end + if @delegate_object[:menu_inherited_lines_edit_always] || lines_count.positive? + dml_menu_append_chrome_item(formatted_choice_ostructs[:edit].dname, lines_count, 'lines', + menu_state: MenuState::EDIT) + end + if lines_count.positive? + dml_menu_append_chrome_item(formatted_choice_ostructs[:save].dname, 1, '', + menu_state: MenuState::SAVE) + end + if lines_count.positive? + dml_menu_append_chrome_item(formatted_choice_ostructs[:view].dname, 1, '', + menu_state: MenuState::VIEW) + end + # rubocop:disable Style/GuardClause + if @delegate_object[:menu_with_shell] + dml_menu_append_chrome_item(formatted_choice_ostructs[:shell].dname, 1, '', + menu_state: MenuState::SHELL) + end + # rubocop:enable Style/GuardClause + + # # reflect new menu items + # @dml_mdoc = MDoc.new(@dml_menu_blocks) + end + + def vux_navigate_back_for_ls + InputSequencer.merge_link_state( + @dml_link_state, + InputSequencer.next_link_state( + **execute_navigate_back.merge(prior_block_was_link: true) + ) + ) + end + + def vux_parse_document + @run_state.batch_index += 1 + @run_state.in_own_window = false + + @run_state.source.block_name_from_cli, @dml_now_using_cli = + manage_cli_selection_state( + block_name_from_cli: @run_state.source.block_name_from_cli, + now_using_cli: @dml_now_using_cli, + link_state: @dml_link_state + ) + + @delegate_object[:filename] = @dml_link_state.document_filename + @dml_link_state.block_name = @delegate_object[:block_name] = + @run_state.source.block_name_from_cli ? + @cli_block_name : + @dml_link_state.block_name + + # update @delegate_object and @menu_base_options in auto_load + # + @dml_blocks_in_file, @dml_menu_blocks, @dml_mdoc = + mdoc_menu_and_blocks_from_nested_files(@dml_link_state) + dump_delobj(@dml_blocks_in_file, @dml_menu_blocks, @dml_link_state) + # &bsp 'loop', @run_state.source.block_name_from_cli, @cli_block_name + end + + def publish_for_external_automation(message:) + return if @delegate_object[:publish_document_file_name].empty? + + pipe_path = absolute_path(@delegate_object[:publish_document_file_name]) + + case @delegate_object[:publish_document_file_mode] + when 'append' + File.write(pipe_path, message + "\n", mode: 'a') + when 'fifo' + unless @vux_pipe_open + unless File.exist?(pipe_path) + FileUtils.mkfifo(pipe_path) + @vux_pipe_created = pipe_path + end + @vux_pipe_open = File.open(pipe_path, 'w') + end + @vux_pipe_open.puts(message + "\n") + @vux_pipe_open.flush + when 'write' + File.write(pipe_path, message) + else + raise 'Invalid publish_document_file_mode:' \ + " #{@delegate_object[:publish_document_file_mode]}" + end + end + + def vux_publish_block_name_for_external_automation(block_name) + publish_for_external_automation( + message: format( + @delegate_object[:publish_block_name_format], + { block: block_name, + document: @delegate_object[:filename], + time: Time.now.utc.strftime( + @delegate_object[:publish_time_format] + ) } + ) + ) + end + + def vux_publish_document_file_name_for_external_automation + return unless @delegate_object[:publish_document_file_name].present? + + publish_for_external_automation( + message: format( + @delegate_object[:publish_document_name_format], + { document: @delegate_object[:filename], + time: Time.now.utc.strftime( + @delegate_object[:publish_time_format] + ) } + ) + ) + end + + # return :break to break from loop + def vux_user_selected_block_name + if @dml_link_state.block_name.present? + # @prior_block_was_link = true + @dml_block_state.block = blocks_find_by_block_name(@dml_blocks_in_file, + @dml_link_state.block_name) + @dml_link_state.block_name = nil + else + # puts "? - Select a block to execute (or type #{$texit} to exit):" + return :break if vux_await_user_selection == :break # into @dml_block_state + return :break if @dml_block_state.block.nil? # no block matched + end + # puts "! - Executing block: #{data}" + @dml_block_state.block&.pub_name + end + + def vux_view_inherited(stream:) + stream.puts @dml_link_state.inherited_lines_block + end + def wait_for_stream_processing @process_mutex.synchronize do @process_cv.wait(@process_mutex) end rescue Interrupt @@ -3163,15 +3451,17 @@ def wait_for_user_selected_block(all_blocks, menu_blocks, default) block_state = wait_for_user_selection(all_blocks, menu_blocks, default) handle_back_or_continue(block_state) block_state - rescue StandardError - HashDelegator.error_handler('wait_for_user_selected_block') end def wait_for_user_selection(_all_blocks, menu_blocks, default) + if @delegate_object[:clear_screen_for_select_block] + printf("\e[1;1H\e[2J") + end + prompt_title = string_send_color( @delegate_object[:prompt_select_block].to_s, :prompt_color_after_script_execution ) menu_items = prepare_blocks_menu(menu_blocks) @@ -3821,53 +4111,51 @@ def setup @hd = HashDelegator.new @hd.instance_variable_set(:@run_state, mock('run_state')) end - def test_format_execution_stream_with_valid_key - result = HashDelegator.format_execution_stream( - { stdout: %w[output1 output2] }, - ExecutionStreams::STD_OUT - ) + # def test_format_execution_stream_with_valid_key + # result = HashDelegator.format_execution_stream( + # { stdout: %w[output1 output2] }, + # ExecutionStreams::STD_OUT + # ) - assert_equal "output1\noutput2", result - end + # assert_equal "output1\noutput2", result + # end - def test_format_execution_stream_with_empty_key - @hd.instance_variable_get(:@run_state).stubs(:files).returns({}) + # def test_format_execution_stream_with_empty_key + # @hd.instance_variable_get(:@run_state).stubs(:files).returns({}) - result = HashDelegator.format_execution_stream(nil, - ExecutionStreams::STD_ERR) + # result = HashDelegator.format_execution_stream(nil, + # ExecutionStreams::STD_ERR) - assert_equal '', result - end + # assert_equal '', result + # end - def test_format_execution_stream_with_nil_files - @hd.instance_variable_get(:@run_state).stubs(:files).returns(nil) + # def test_format_execution_stream_with_nil_files + # @hd.instance_variable_get(:@run_state).stubs(:files).returns(nil) - result = HashDelegator.format_execution_stream(nil, :stdin) + # result = HashDelegator.format_execution_stream(nil, :stdin) - assert_equal '', result - end + # assert_equal '', result + # end end class TestHashDelegatorHandleBackLink < Minitest::Test def setup @hd = HashDelegator.new @hd.stubs(:history_state_pop) end - def test_pop_link_history_and_trigger_load + def test_pop_link_history_new_state # Verifying that history_state_pop is called # @hd.expects(:history_state_pop).once - result = @hd.pop_link_history_and_trigger_load + result = @hd.pop_link_history_new_state - # Asserting the result is an instance of LoadFileLinkState - assert_instance_of LoadFileLinkState, result - assert_equal LoadFile::LOAD, result.load_file - assert_nil result.link_state.block_name + # Asserting the result is an instance of LinkState + assert_nil result.block_name end end class TestHashDelegatorHandleBlockState < Minitest::Test def setup @@ -3950,11 +4238,11 @@ class TestHashDelegatorHandleStream < Minitest::Test def setup @hd = HashDelegator.new @hd.instance_variable_set(:@run_state, - OpenStruct.new(files: { stdout: [] })) + OpenStruct.new(files: StreamsOut.new)) @hd.instance_variable_set(:@delegate_object, { output_stdout: true }) end def test_handle_stream @@ -3962,13 +4250,12 @@ file_type = ExecutionStreams::STD_OUT Thread.new { @hd.handle_stream(stream: stream, file_type: file_type) } @hd.wait_for_stream_processing - assert_equal ['line 1', 'line 2'], - @hd.instance_variable_get(:@run_state).files[ExecutionStreams::STD_OUT] + @hd.instance_variable_get(:@run_state).files.stream_lines(ExecutionStreams::STD_OUT) end def test_handle_stream_with_io_error stream = StringIO.new("line 1\nline 2\n") file_type = ExecutionStreams::STD_OUT @@ -3977,11 +4264,11 @@ Thread.new { @hd.handle_stream(stream: stream, file_type: file_type) } @hd.wait_for_stream_processing assert_equal [], - @hd.instance_variable_get(:@run_state).files[ExecutionStreams::STD_OUT] + @hd.instance_variable_get(:@run_state).files.stream_lines(ExecutionStreams::STD_OUT) end end class TestHashDelegatorIterBlocksFromNestedFiles < Minitest::Test def setup @@ -3995,13 +4282,13 @@ end def test_iter_blocks_from_nested_files @hd.cfile.expect(:readlines, ['line 1', 'line 2'], ['test.md'], import_paths: nil) - selected_messages = ['filtered message'] + selected_types = ['filtered message'] - result = @hd.iter_blocks_from_nested_files { selected_messages } + result = @hd.iter_blocks_from_nested_files { selected_types } assert_equal ['line 1', 'line 2'], result @hd.cfile.verify end @@ -4022,15 +4309,15 @@ menu_chrome_color: :red, menu_chrome_format: '-- %s --' }) @hd.stubs(:menu_chrome_formatted_option).with(:menu_option_back_name).returns('-- Back --') @hd.stubs(:string_send_color).with('-- Back --', - :menu_chrome_color).returns('-- Back --'.red) + :menu_chrome_color).returns(AnsiString.new('-- Back --').red) end def test_menu_chrome_colored_option_with_color - assert_equal '-- Back --'.red, + assert_equal AnsiString.new('-- Back --').red, @hd.menu_chrome_colored_option(:menu_option_back_name) end def test_menu_chrome_colored_option_without_color @hd.instance_variable_set(:@delegate_object, @@ -4090,14 +4377,15 @@ @hd.instance_variable_set(:@delegate_object, { red: 'red', green: 'green' }) end def test_string_send_color - assert_equal 'Hello'.red, @hd.string_send_color('Hello', :red) - assert_equal 'World'.green, + assert_equal AnsiString.new('Hello').red, + @hd.string_send_color('Hello', :red) + assert_equal AnsiString.new('World').green, @hd.string_send_color('World', :green) - assert_equal 'Default'.plain, + assert_equal AnsiString.new('Default').plain, @hd.string_send_color('Default', :blue) end end def test_yield_line_if_selected_with_line @@ -4283,13 +4571,10 @@ assert_equal 'resolved_path_or_substituted_value', result end def test_prompt_for_filespec_with_interruption $stdin = StringIO.new - # rubocop disable:Lint/NestedMethodDefinition def $stdin.gets; raise Interrupt; end - # rubocop enable:Lint/NestedMethodDefinition - result = prompt_for_filespec_with_wildcard('*.txt') assert_nil result end def test_prompt_for_filespec_with_empty_input