lib/markdown_exec.rb in markdown_exec-1.3.9 vs lib/markdown_exec.rb in markdown_exec-1.4

- old
+ new

@@ -17,34 +17,31 @@ require_relative 'cli' require_relative 'colorize' require_relative 'env' require_relative 'fcb' require_relative 'filter' +require_relative 'markdown_exec/version' require_relative 'mdoc' require_relative 'option_value' require_relative 'saved_assets' require_relative 'saved_files_matcher' require_relative 'shared' require_relative 'tap' -require_relative 'markdown_exec/version' include CLI include Tap tap_config envvar: MarkdownExec::TAP_DEBUG $stderr.sync = true $stdout.sync = true -BLOCK_SIZE = 1024 +MDE_HISTORY_ENV_NAME = 'MDE_MENU_HISTORY' # macros # -BACK_OPTION = '* Back' -EXIT_OPTION = '* Exit' LOAD_FILE = true -VN = 'MDE_MENU_HISTORY' # custom error: file specified is missing # class FileMissingError < StandardError; end @@ -127,26 +124,61 @@ end # convert regex match groups to a hash with symbol keys # # :reek:UtilityFunction -def option_match_groups(str, option) +def extract_named_captures_from_option(str, option) str.match(Regexp.new(option))&.named_captures&.sym_keys end +module ArrayUtil + def self.partition_by_predicate(arr) + true_list = [] + false_list = [] + + arr.each do |element| + if yield(element) + true_list << element + else + false_list << element + end + end + + [true_list, false_list] + end +end + +module StringUtil + # Splits the given string on the first occurrence of the specified character. + # Returns an array containing the portion of the string before the character and the rest of the string. + # + # @param input_str [String] The string to be split. + # @param split_char [String] The character on which to split the string. + # @return [Array<String>] An array containing two elements: the part of the string before split_char, and the rest of the string. + def self.partition_at_first(input_str, split_char) + split_index = input_str.index(split_char) + + if split_index.nil? + [input_str, ''] + else + [input_str[0...split_index], input_str[(split_index + 1)..-1]] + end + end +end + # execute markdown documents # module MarkdownExec # :reek:IrresponsibleModule FNR11 = '/' FNR12 = ',~' SHELL_COLOR_OPTIONS = { - 'bash' => :menu_bash_color, + BLOCK_TYPE_BASH => :menu_bash_color, BLOCK_TYPE_LINK => :menu_link_color, - 'opts' => :menu_opts_color, - 'vars' => :menu_vars_color + BLOCK_TYPE_OPTS => :menu_opts_color, + BLOCK_TYPE_VARS => :menu_vars_color }.freeze ## # # rubocop:disable Layout/LineLength @@ -156,27 +188,94 @@ # :reek:TooManyInstanceVariables ### temp # :reek:TooManyMethods ### temp class MarkParse attr_reader :options + include ArrayUtil + include StringUtil include FOUT def initialize(options = {}) - @options = options - # hide disabled symbol - @prompt = TTY::Prompt.new(interrupt: :exit, symbols: { cross: ' ' }) @execute_aborted_at = nil @execute_completed_at = nil @execute_error = nil @execute_error_message = nil @execute_files = nil @execute_options = nil @execute_script_filespec = nil @execute_started_at = nil @option_parser = nil + @options = options + @prompt = tty_prompt_without_disabled_symbol end + ## + # Appends a summary of a block (FCB) to the blocks array. + # + def append_block_summary(blocks, fcb, opts) + ## enhance fcb with block summary + # + blocks.push get_block_summary(opts, fcb) + end + + ## + # Appends a final divider to the blocks array if it is specified in options. + # + def append_final_divider(blocks, opts) + return unless opts[:menu_divider_format].present? && opts[:menu_final_divider].present? + + blocks.push FCB.new( + { chrome: true, + disabled: '', + dname: format(opts[:menu_divider_format], + opts[:menu_final_divider]) + .send(opts[:menu_divider_color].to_sym), + oname: opts[:menu_final_divider] } + ) + end + + ## + # Appends an initial divider to the blocks array if it is specified in options. + # + def append_initial_divider(blocks, opts) + return unless opts[:menu_initial_divider].present? + + blocks.push FCB.new({ + # name: '', + chrome: true, + dname: format( + opts[:menu_divider_format], + opts[:menu_initial_divider] + ).send(opts[:menu_divider_color].to_sym), + oname: opts[:menu_initial_divider], + disabled: '' # __LINE__.to_s + }) + end + + # Execute a code block after approval and provide user interaction options. + # + # This method displays required code blocks, asks for user approval, and + # executes the code block if approved. It also allows users to copy the + # 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 approve_and_execute_block(opts, mdoc) + selected = mdoc.get_block_by_name(opts[:block_name]) + + if selected.fetch(:shell, '') == BLOCK_TYPE_LINK + handle_shell_link(opts, selected.fetch(:body, ''), mdoc) + elsif opts.fetch(:back, false) + handle_back_link(opts) + elsif selected[:shell] == BLOCK_TYPE_OPTS + handle_shell_opts(opts, selected) + else + handle_remainder_blocks(mdoc, opts, selected) + end + end + # return arguments before `--` # def arguments_for_mde(argv = ARGV) case ind = argv.find_index('--') when nil @@ -204,124 +303,50 @@ end [item[:opt_name], item[:proccode] ? item[:proccode].call(value) : value] end.compact.to_h end + def blocks_per_opts(blocks, opts) + return blocks if opts[:struct] + + blocks.map do |block| + block.fetch(:text, nil) || block.oname + end.compact.reject(&:empty?) + end + def calculated_options { bash: true, # bash block parsing in get_block_summary() saved_script_filename: nil, # calculated struct: true # allow get_block_summary() } end - # Execute a code block after approval and provide user interaction options. - # - # This method displays required code blocks, asks for user approval, and - # executes the code block if approved. It also allows users to copy the - # 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. - # @return [String] The name of the executed code block. - # - def approve_and_execute_block(opts, mdoc) - selected = mdoc.get_block_by_name(opts[:block_name]) - if selected[:shell] == BLOCK_TYPE_LINK - handle_link_shell(opts, selected) - elsif selected[:shell] == 'opts' - handle_opts_shell(opts, selected) - else - required_lines = collect_required_code_blocks(opts, mdoc, selected) - # Display required code blocks if requested or required approval. - if opts[:output_script] || opts[:user_must_approve] - display_required_code(opts, required_lines) - end - - allow = true - allow = user_approval(opts, required_lines) if opts[:user_must_approve] - opts[:ir_approve] = allow - mdoc.get_block_by_name(opts[:block_name]) - execute_approved_block(opts, required_lines) if opts[:ir_approve] - - [!LOAD_FILE, ''] + # Check whether the document exists and is readable + def check_file_existence(filename) + unless filename&.present? + fout 'No blocks found.' + return false end - end - def handle_link_shell(opts, selected) - data = YAML.load(selected[:body].join("\n")) - - # add to front of history - # - ENV[VN] = opts[:filename] + opts[:history_document_separator] + ENV.fetch(VN, '') - - opts[:filename] = data.fetch('file', nil) - return !LOAD_FILE unless opts[:filename] - - data.fetch('vars', []).each do |var| - ENV[var[0]] = var[1].to_s + unless File.exist? filename + fout 'Document is missing.' + return false end - - [LOAD_FILE, data.fetch('block', '')] + true end - def handle_opts_shell(opts, selected) - data = YAML.load(selected[:body].join("\n")) - data.each_key do |key| - opts[key.to_sym] = value = data[key].to_s - next unless opts[:menu_opts_set_format].present? - - print format( - opts[:menu_opts_set_format], - { key: key, - value: value } - ).send(opts[:menu_opts_set_color].to_sym) - end - [!LOAD_FILE, ''] + def clear_required_file + ENV['MDE_LINK_REQUIRED_FILE'] = '' end - def user_approval(opts, required_lines) - # Present a selection menu for user approval. - sel = @prompt.select(opts[:prompt_approve_block], filter: true) do |menu| - menu.default 1 - menu.choice opts[:prompt_yes], 1 - menu.choice opts[:prompt_no], 2 - menu.choice opts[:prompt_script_to_clipboard], 3 - menu.choice opts[:prompt_save_script], 4 - end - - if sel == 3 - copy_to_clipboard(required_lines) - elsif sel == 4 - save_to_file(opts, required_lines) - end - - sel == 1 - end - - def execute_approved_block(opts, required_lines) - write_command_file(opts, required_lines) - command_execute( - opts, - required_lines.flatten.join("\n"), - args: opts.fetch(:pass_args, []) - ) - save_execution_output - output_execution_summary - output_execution_result - end - # Collect required code blocks based on the provided options. # # @param opts [Hash] Options hash containing configuration settings. # @param mdoc [YourMDocClass] An instance of the MDoc class. # @return [Array<String>] Required code blocks as an array of lines. - def collect_required_code_blocks(opts, mdoc, selected) - required = mdoc.collect_recursively_required_code(opts[:block_name]) - required_lines = required[:code] - required[:blocks] - + def collect_required_code_lines(mdoc, selected, opts: {}) # Apply hash in opts block to environment variables if selected[:shell] == BLOCK_TYPE_VARS data = YAML.load(selected[:body].join("\n")) data.each_key do |key| ENV[key] = value = data[key].to_s @@ -333,37 +358,24 @@ value: value } ).send(opts[:menu_vars_set_color].to_sym) end end - required_lines + required = mdoc.collect_recursively_required_code(opts[:block_name], opts: opts) + read_required_blocks_from_temp_file + required[:code] end def cfile - @cfile ||= CachedNestedFileReader.new(import_pattern: @options.fetch(:import_pattern)) + @cfile ||= CachedNestedFileReader.new( + import_pattern: @options.fetch(:import_pattern) + ) end EF_STDOUT = :stdout EF_STDERR = :stderr EF_STDIN = :stdin - # Handles reading and processing lines from a given IO stream - # - # @param stream [IO] The IO stream to read from (e.g., stdout, stderr, stdin). - # @param file_type [Symbol] The type of file to which the stream corresponds. - def handle_stream(opts, stream, file_type, swap: false) - Thread.new do - until (line = stream.gets).nil? - @execute_files[file_type] = @execute_files[file_type] + [line.strip] - print line if opts[:output_stdout] - yield line if block_given? - end - rescue IOError - #d 'stdout IOError, thread killed, do nothing' - end - end - # Existing command_execute method def command_execute(opts, command, args: []) @execute_files = Hash.new([]) @execute_options = opts @execute_started_at = Time.now.utc @@ -402,36 +414,109 @@ @execute_error = err @execute_files[EF_STDERR] += [@execute_error_message] fout "Error ENOENT: #{err.inspect}" end + def copy_to_clipboard(required_lines) + text = required_lines.flatten.join($INPUT_RECORD_SEPARATOR) + Clipboard.copy(text) + fout "Clipboard updated: #{required_lines.count} blocks," \ + " #{required_lines.flatten.count} lines," \ + " #{text.length} characters" + end + def count_blocks_in_filename fenced_start_and_end_regex = Regexp.new @options[:fenced_start_and_end_regex] cnt = 0 cfile.readlines(@options[:filename]).each do |line| cnt += 1 if line.match(fenced_start_and_end_regex) end cnt / 2 end + def create_and_write_file_with_permissions(file_path, content, chmod_value) + dirname = File.dirname(file_path) + FileUtils.mkdir_p dirname + File.write(file_path, content) + return if chmod_value.zero? + + File.chmod chmod_value, file_path + end + + # Deletes a required temporary file specified by an environment variable. + # The function checks if the file exists before attempting to delete it. + # Clears the environment variable after deletion. + # + def delete_required_temp_file + temp_blocks_file_path = ENV.fetch('MDE_LINK_REQUIRED_FILE', nil) + + return if temp_blocks_file_path.nil? || temp_blocks_file_path.empty? + + FileUtils.rm_f(temp_blocks_file_path) + + clear_required_file + end + + ## Determines the correct filename to use for searching files + # + def determine_filename(specified_filename: nil, specified_folder: nil, default_filename: nil, + default_folder: nil, filetree: nil) + if specified_filename&.present? + return specified_filename if specified_filename.start_with?('/') + + File.join(specified_folder || default_folder, specified_filename) + elsif specified_folder&.present? + File.join(specified_folder, + filetree ? @options[:md_filename_match] : @options[:md_filename_glob]) + else + File.join(default_folder, default_filename) + end + end + # :reek:DuplicateMethodCall def display_required_code(opts, required_lines) frame = opts[:output_divider].send(opts[:output_divider_color].to_sym) fout frame required_lines.each { |cb| fout cb } fout frame end - # :reek:DuplicateMethodCall - def exec_block(options, _block_name = '') - options = calculated_options.merge(options) - update_options options, over: false + def execute_approved_block(opts, required_lines) + write_command_file(opts, required_lines) + command_execute( + opts, + required_lines.flatten.join("\n"), + args: opts.fetch(:pass_args, []) + ) + initialize_and_save_execution_output + output_execution_summary + output_execution_result + end - # document and block reports - # - files = list_files_per_options(options) + # Reports and executes block logic + def execute_block_logic(files) + @options[:filename] = select_document_if_multiple(files) + select_approve_and_execute_block({ + bash: true, + struct: true + }) + end + ## Executes the block specified in the options + # + def execute_block_with_error_handling(rest) + finalize_cli_argument_processing(rest) + execute_code_block_based_on_options(@options, @options[:block_name]) + rescue FileMissingError => err + puts "File missing: #{err}" + end + + # Main method to execute a block based on options and block_name + def execute_code_block_based_on_options(options, _block_name = '') + options = calculated_options.merge(options) + update_options(options, over: false) + simple_commands = { doc_glob: -> { fout options[:md_filename_glob] }, list_blocks: lambda do fout_list (files.map do |file| make_block_labels(filename: file, struct: true) @@ -457,31 +542,63 @@ select_recent_output: -> { select_recent_output }, select_recent_script: -> { select_recent_script }, tab_completions: -> { fout tab_completions }, menu_export: -> { fout menu_export } } + + return if execute_simple_commands(simple_commands) + + files = prepare_file_list(options) + execute_block_logic(files) + return unless @options[:output_saved_script_filename] + + fout "saved_filespec: #{@execute_script_filespec}" + rescue StandardError => err + warn(error = "ERROR ** MarkParse.execute_code_block_based_on_options(); #{err.inspect}") + binding.pry if $tap_enable + raise ArgumentError, error + end + + # Executes command based on the provided option keys + def execute_simple_commands(simple_commands) simple_commands.each_key do |key| if @options[key] simple_commands[key].call - return # rubocop:disable Lint/NonLocalExitFromIterator + return true end end + false + end - # process + ## + # Determines the types of blocks to select based on the filter. + # + def filter_block_types + ## return type of blocks to select # - @options[:filename] = select_md_file(files) - select_approve_and_execute_block({ - bash: true, - struct: true - }) - return unless @options[:output_saved_script_filename] + %i[blocks line] + end - fout "saved_filespec: #{@execute_script_filespec}" - rescue StandardError => err - warn(error = "ERROR ** MarkParse.exec_block(); #{err.inspect}") - binding.pry if $tap_enable - raise ArgumentError, error + ## post-parse options configuration + # + def finalize_cli_argument_processing(rest) + ## position 0: file or folder (optional) + # + if (pos = rest.shift)&.present? + if Dir.exist?(pos) + @options[:path] = pos + elsif File.exist?(pos) + @options[:filename] = pos + else + raise FileMissingError, pos, caller + end + end + + ## position 1: block name (optional) + # + block_name = rest.shift + @options[:block_name] = block_name if block_name.present? end ## summarize blocks # def get_block_summary(call_options, fcb) @@ -493,13 +610,13 @@ titlexcall = if fcb.call fcb.title.sub("%#{fcb.call}", '') else fcb.title end - bm = option_match_groups(titlexcall, opts[:block_name_match]) - fcb.stdin = option_match_groups(titlexcall, opts[:block_stdin_scan]) - fcb.stdout = option_match_groups(titlexcall, opts[:block_stdout_scan]) + bm = extract_named_captures_from_option(titlexcall, opts[:block_name_match]) + fcb.stdin = extract_named_captures_from_option(titlexcall, opts[:block_stdin_scan]) + fcb.stdout = extract_named_captures_from_option(titlexcall, opts[:block_stdout_scan]) shell_color_option = SHELL_COLOR_OPTIONS[fcb[:shell]] fcb.title = fcb.oname = bm && bm[1] ? bm[:title] : titlexcall fcb.dname = if shell_color_option && opts[shell_color_option].present? fcb.oname.send(opts[shell_color_option].to_sym) @@ -507,224 +624,274 @@ fcb.oname end fcb end - # :reek:DuplicateMethodCall - # :reek:LongYieldList - # :reek:NestedIterators - #--- + ## + # Handles errors that occur during the block listing process. + # + def handle_error(err) + warn(error = "ERROR ** MarkParse.list_blocks_in_file(); #{err.inspect}") + warn(caller[0..4]) + raise StandardError, error + end - def iter_blocks_in_file(opts = {}, &block) - unless opts[:filename]&.present? - fout 'No blocks found.' - return - end + # Handles the link-back operation. + # + # @param opts [Hash] Configuration options hash. + # @return [Array<Symbol, String>] A tuple containing a LOAD_FILE flag and an empty string. + def handle_back_link(opts) + history_state_pop(opts) + [LOAD_FILE, ''] + end - unless File.exist? opts[:filename] - fout 'Document is missing.' - return + # Handles the execution and display of remainder blocks from a selected menu item. + # + # @param mdoc [Object] Document object containing code blocks. + # @param opts [Hash] Configuration options hash. + # @param selected [Hash] Selected item from the menu. + # @return [Array<Symbol, String>] A tuple containing a LOAD_FILE flag and an empty string. + # @note The function can prompt the user for approval before executing code if opts[:user_must_approve] is true. + def handle_remainder_blocks(mdoc, opts, selected) + required_lines = collect_required_code_lines(mdoc, selected, opts: opts) + if opts[:output_script] || opts[:user_must_approve] + display_required_code(opts, required_lines) end + allow = opts[:user_must_approve] ? prompt_for_user_approval(opts, required_lines) : true + opts[:ir_approve] = allow + execute_approved_block(opts, required_lines) if opts[:ir_approve] - fenced_start_and_end_regex = Regexp.new opts[:fenced_start_and_end_regex] - fenced_start_extended_regex = Regexp.new opts[:fenced_start_extended_regex] - fcb = FCB.new - in_fenced_block = false - headings = [] + [!LOAD_FILE, ''] + end - ## get type of messages to select - # - selected_messages = yield :filter + # Handles the link-shell operation. + # + # @param opts [Hash] Configuration options hash. + # @param body [Array<String>] The body content. + # @param mdoc [Object] Document object containing code blocks. + # @return [Array<Symbol, String>] A tuple containing a LOAD_FILE flag and a block name. + def handle_shell_link(opts, body, mdoc) + data = body.present? ? YAML.load(body.join("\n")) : {} + data_file = data.fetch('file', nil) + return [!LOAD_FILE, ''] unless data_file - cfile.readlines(opts[:filename]).each.with_index do |line, _line_num| - continue unless line - headings = update_headings(line, headings, opts) if opts[:menu_blocks_with_headings] + history_state_push(mdoc, data_file, opts) - if line.match(fenced_start_and_end_regex) - if in_fenced_block - process_fenced_block(fcb, opts, selected_messages, &block) - in_fenced_block = false - else - fcb = start_fenced_block(opts, line, headings, fenced_start_extended_regex) - in_fenced_block = true - end - elsif in_fenced_block && fcb.body - dp 'append line to fcb body' - fcb.body += [line.chomp] - else - process_line(line, opts, selected_messages, &block) - end + data.fetch('vars', []).each do |var| + ENV[var[0]] = var[1].to_s end + + [LOAD_FILE, data.fetch('block', '')] end - def start_fenced_block(opts, line, headings, fenced_start_extended_regex) - fcb_title_groups = line.match(fenced_start_extended_regex).named_captures.sym_keys - fcb = FCB.new - fcb.headings = headings - fcb.oname = fcb.dname = fcb_title_groups.fetch(:name, '') - fcb.shell = fcb_title_groups.fetch(:shell, '') - fcb.title = fcb_title_groups.fetch(:name, '') + # Handles options for the shell. + # + # @param opts [Hash] Configuration options hash. + # @param selected [Hash] Selected item from the menu. + # @return [Array<Symbol, String>] A tuple containing a NOT_LOAD_FILE flag and an empty string. + def handle_shell_opts(opts, selected) + data = YAML.load(selected[:body].join("\n")) + data.each_key do |key| + opts[key.to_sym] = value = data[key].to_s + next unless opts[:menu_opts_set_format].present? - # selected fcb - fcb.body = [] - - rest = fcb_title_groups.fetch(:rest, '') - fcb.reqs, fcb.wraps = - split_array(rest.scan(/\+[^\s]+/).map { |req| req[1..-1] }) do |name| - !name.match(Regexp.new(opts[:block_name_wrapper_match])) + print format( + opts[:menu_opts_set_format], + { key: key, + value: value } + ).send(opts[:menu_opts_set_color].to_sym) end - fcb.call = rest.match(Regexp.new(opts[:block_calls_scan]))&.to_a&.first - fcb.stdin = if (tn = rest.match(/<(?<type>\$)?(?<name>[A-Za-z_-]\S+)/)) - tn.named_captures.sym_keys - end - fcb.stdout = if (tn = rest.match(/>(?<type>\$)?(?<name>[A-Za-z_\-.\w]+)/)) - tn.named_captures.sym_keys - end - fcb + [!LOAD_FILE, ''] end - def process_fenced_block(fcb, opts, selected_messages, &block) - fcb.oname = fcb.dname = fcb.title || '' - return unless fcb.body - - set_fcb_title(fcb) - - if block && - selected_messages.include?(:blocks) && - Filter.fcb_select?(opts, fcb) - block.call :blocks, fcb + # Handles reading and processing lines from a given IO stream + # + # @param stream [IO] The IO stream to read from (e.g., stdout, stderr, stdin). + # @param file_type [Symbol] The type of file to which the stream corresponds. + def handle_stream(opts, stream, file_type, swap: false) + Thread.new do + until (line = stream.gets).nil? + @execute_files[file_type] = @execute_files[file_type] + [line.strip] + print line if opts[:output_stdout] + yield line if block_given? + end + rescue IOError + #d 'stdout IOError, thread killed, do nothing' end end - def process_line(line, _opts, selected_messages, &block) - return unless block && selected_messages.include?(:line) + def history_state_exist? + history = ENV.fetch(MDE_HISTORY_ENV_NAME, '') + history.present? ? history : nil + end - # dp 'text outside of fcb' - fcb = FCB.new - fcb.body = [line] - block.call(:line, fcb) + def history_state_partition(opts) + unit, rest = StringUtil.partition_at_first( + ENV.fetch(MDE_HISTORY_ENV_NAME, ''), + opts[:history_document_separator] + ) + { unit: unit, rest: rest }.tap_inspect end - # set the title of an FCB object based on its body if it is nil or empty - def set_fcb_title(fcb) - return unless fcb.title.nil? || fcb.title.empty? + def history_state_pop(opts) + state = history_state_partition(opts) + opts[:filename] = state[:unit] + ENV[MDE_HISTORY_ENV_NAME] = state[:rest] + delete_required_temp_file + end - fcb.title = (fcb&.body || []).join(' ').gsub(/ +/, ' ')[0..64] + def history_state_push(mdoc, data_file, opts) + [data_file, opts[:block_name]].tap_inspect 'filename, blockname' + new_history = opts[:filename] + + opts[:history_document_separator] + + ENV.fetch(MDE_HISTORY_ENV_NAME, '') + opts[:filename] = data_file + write_required_blocks_to_temp_file(mdoc, opts[:block_name], opts) + ENV[MDE_HISTORY_ENV_NAME] = new_history end - def split_array(arr) - true_list = [] - false_list = [] + ## Sets up the options and returns the parsed arguments + # + def initialize_and_parse_cli_options + @options = base_options + read_configuration_file!(@options, ".#{MarkdownExec::APP_NAME.downcase}.yml") - arr.each do |element| - if yield(element) - true_list << element - else - false_list << element + @option_parser = OptionParser.new do |opts| + executable_name = File.basename($PROGRAM_NAME) + opts.banner = [ + "#{MarkdownExec::APP_NAME}" \ + " - #{MarkdownExec::APP_DESC} (#{MarkdownExec::VERSION})", + "Usage: #{executable_name} [(path | filename [block_name])] [options]" + ].join("\n") + + menu_iter do |item| + menu_option_append opts, @options, item end end + @option_parser.load + @option_parser.environment - [true_list, false_list] + rest = @option_parser.parse!(arguments_for_mde) + @options[:pass_args] = ARGV[rest.count + 1..] + + rest end - def update_headings(line, headings, opts) - if (lm = line.match(Regexp.new(opts[:heading3_match]))) - [headings[0], headings[1], lm[:name]] - elsif (lm = line.match(Regexp.new(opts[:heading2_match]))) - [headings[0], lm[:name]] - elsif (lm = line.match(Regexp.new(opts[:heading1_match]))) - [lm[:name]] - else - headings + def initialize_and_save_execution_output + return unless @options[:save_execution_output] + + @options[:logged_stdout_filename] = + SavedAsset.stdout_name(blockname: @options[:block_name], + filename: File.basename(@options[:filename], '.*'), + prefix: @options[:logged_stdout_filename_prefix], + time: Time.now.utc) + + @logged_stdout_filespec = + @options[:logged_stdout_filespec] = + File.join @options[:saved_stdout_folder], + @options[:logged_stdout_filename] + @logged_stdout_filespec = @options[:logged_stdout_filespec] + write_execution_output_to_file + end + + # Initializes variables for regex and other states + def initialize_state(opts) + { + fenced_start_and_end_regex: Regexp.new(opts[:fenced_start_and_end_regex]), + fenced_start_extended_regex: Regexp.new(opts[:fenced_start_extended_regex]), + fcb: FCB.new, + in_fenced_block: false, + headings: [] + } + end + + # Main function to iterate through blocks in file + def iter_blocks_in_file(opts = {}, &block) + return unless check_file_existence(opts[:filename]) + + state = initialize_state(opts) + + # get type of messages to select + selected_messages = yield :filter + + cfile.readlines(opts[:filename]).each do |line| + next unless line + + update_line_and_block_state(line, state, opts, selected_messages, &block) end end - # return body, title if option.struct - # return body if not struct + ## + # Returns a list of blocks in a given file, including dividers, tasks, and other types of blocks. + # The list can be customized via call_options and options_block. # + # @param call_options [Hash] Options passed as an argument. + # @param options_block [Proc] Block for dynamic option manipulation. + # @return [Array<FCB>] An array of FCB objects representing the blocks. + # def list_blocks_in_file(call_options = {}, &options_block) opts = optsmerge(call_options, options_block) use_chrome = !opts[:no_chrome] blocks = [] - if opts[:menu_initial_divider].present? && use_chrome - blocks.push FCB.new({ - # name: '', - chrome: true, - dname: format( - opts[:menu_divider_format], - opts[:menu_initial_divider] - ).send(opts[:menu_divider_color].to_sym), - oname: opts[:menu_initial_divider], - disabled: '' # __LINE__.to_s - }) - end + append_initial_divider(blocks, opts) if use_chrome iter_blocks_in_file(opts) do |btype, fcb| case btype when :filter - ## return type of blocks to select - # - %i[blocks line] - + filter_block_types when :line - ## convert line to block - # - if opts[:menu_divider_match].present? && - (mbody = fcb.body[0].match opts[:menu_divider_match]) - if use_chrome - blocks.push FCB.new( - { chrome: true, - disabled: '', - dname: format(opts[:menu_divider_format], - mbody[:name]).send(opts[:menu_divider_color].to_sym), - oname: mbody[:name] } - ) - end - elsif opts[:menu_task_match].present? && - (fcb.body[0].match opts[:menu_task_match]) - if use_chrome - blocks.push FCB.new( - { chrome: true, - disabled: '', - dname: format( - opts[:menu_task_format], - $~.named_captures.transform_keys(&:to_sym) - ).send(opts[:menu_task_color].to_sym), - oname: format( - opts[:menu_task_format], - $~.named_captures.transform_keys(&:to_sym) - ) } - ) - end - else - # line not added - end + process_line_blocks(blocks, fcb, opts, use_chrome) when :blocks - ## enhance fcb with block summary - # - blocks.push get_block_summary(opts, fcb) ### if Filter.fcb_select? opts, fcb + append_block_summary(blocks, fcb, opts) end end - if opts[:menu_divider_format].present? && opts[:menu_final_divider].present? && use_chrome && use_chrome - blocks.push FCB.new( - { chrome: true, - disabled: '', - dname: format(opts[:menu_divider_format], - opts[:menu_final_divider]) - .send(opts[:menu_divider_color].to_sym), - oname: opts[:menu_final_divider] } - ) - end + append_final_divider(blocks, opts) if use_chrome blocks rescue StandardError => err - warn(error = "ERROR ** MarkParse.list_blocks_in_file(); #{err.inspect}") - warn(caller[0..4]) - raise StandardError, error + handle_error(err) end + ## + # Processes lines within the file and converts them into blocks if they match certain criteria. + # + def process_line_blocks(blocks, fcb, opts, use_chrome) + ## convert line to block + # + if opts[:menu_divider_match].present? && + (mbody = fcb.body[0].match opts[:menu_divider_match]) + if use_chrome + blocks.push FCB.new( + { chrome: true, + disabled: '', + dname: format(opts[:menu_divider_format], + mbody[:name]).send(opts[:menu_divider_color].to_sym), + oname: mbody[:name] } + ) + end + elsif opts[:menu_task_match].present? && + (fcb.body[0].match opts[:menu_task_match]) + if use_chrome + blocks.push FCB.new( + { chrome: true, + disabled: '', + dname: format( + opts[:menu_task_format], + $~.named_captures.transform_keys(&:to_sym) + ).send(opts[:menu_task_color].to_sym), + oname: format( + opts[:menu_task_format], + $~.named_captures.transform_keys(&:to_sym) + ) } + ) + end + else + # line not added + end + end + def list_default_env menu_iter do |item| next unless item[:env_var].present? [ @@ -745,62 +912,34 @@ end.compact.sort end def list_files_per_options(options) list_files_specified( - specified_filename: options[:filename]&.present? ? options[:filename] : nil, - specified_folder: options[:path], - default_filename: 'README.md', - default_folder: '.' + determine_filename( + specified_filename: options[:filename]&.present? ? options[:filename] : nil, + specified_folder: options[:path], + default_filename: 'README.md', + default_folder: '.' + ) ) end - # :reek:LongParameterList - def list_files_specified(specified_filename: nil, specified_folder: nil, - default_filename: nil, default_folder: nil, filetree: nil) - fn = File.join(if specified_filename&.present? - if specified_filename.start_with? '/' - [specified_filename] - elsif specified_folder&.present? - [specified_folder, specified_filename] - else - [default_folder, specified_filename] - end - elsif specified_folder&.present? - if filetree - [specified_folder, @options[:md_filename_match]] - else - [specified_folder, @options[:md_filename_glob]] - end - else - [default_folder, default_filename] - end) - if filetree - filetree.select do |filename| - filename == fn || filename.match(/^#{fn}$/) || filename.match(%r{^#{fn}/.+$}) - end - else - Dir.glob(fn) + ## Searches for files based on the specified or default filenames and folders + # + def list_files_specified(fn, filetree = nil) + return Dir.glob(fn) unless filetree + + filetree.select do |filename| + filename == fn || filename.match(/^#{fn}$/) || filename.match(%r{^#{fn}/.+$}) end end def list_markdown_files_in_path Dir.glob(File.join(@options[:path], @options[:md_filename_glob])) end - def blocks_per_opts(blocks, opts) - if opts[:struct] - blocks - else - # blocks.map(&:name) - blocks.map do |block| - block.fetch(:text, nil) || block.oname - end - end.compact.reject(&:empty?) - end - ## output type (body string or full object) per option struct and bash # def list_named_blocks_in_file(call_options = {}, &options_block) opts = optsmerge call_options, options_block @@ -809,10 +948,21 @@ Filter.fcb_select?(opts.merge(no_chrome: true), fcb) end blocks_per_opts(blocks, opts) end + ## Handles the file loading and returns the blocks in the file and MDoc instance + # + def load_file_and_prepare_menu(opts) + blocks_in_file = list_blocks_in_file(opts.merge(struct: true)) + mdoc = MDoc.new(blocks_in_file) do |nopts| + opts.merge!(nopts) + end + blocks_menu = mdoc.fcbs_per_options(opts.merge(struct: true)) + [blocks_in_file, blocks_menu, mdoc] + end + def make_block_labels(call_options = {}) opts = options.merge(call_options) list_blocks_in_file(opts).map do |fcb| BlockLabel.make( filename: opts[:filename], @@ -824,10 +974,36 @@ body: fcb[:body] ) end.compact end + def menu_export(data = menu_for_optparse) + data.map do |item| + item.delete(:procname) + item + end.to_yaml + end + + def menu_for_blocks(menu_options) + options = calculated_options.merge menu_options + menu = [] + iter_blocks_in_file(options) do |btype, fcb| + case btype + when :filter + %i[blocks line] + when :line + if options[:menu_divider_match] && + (mbody = fcb.body[0].match(options[:menu_divider_match])) + menu.push FCB.new({ dname: mbody[:name], oname: mbody[:name], disabled: '' }) + end + when :blocks + menu += [fcb.oname] + end + end + menu + end + # :reek:DuplicateMethodCall # :reek:NestedIterators def menu_for_optparse menu_from_yaml.map do |menu_item| menu_item.merge( @@ -847,15 +1023,15 @@ fout menu_help exit } when 'path' lambda { |value| - read_configuration_file! options, value + read_configuration_file!(options, value) } when 'show_config' lambda { |_| - options_finalize options + finalize_cli_argument_processing(options) fout options.sort_by_key.to_yaml } when 'val_as_bool' lambda { |value| value.instance_of?(::String) ? (value.chomp != '0') : value @@ -875,37 +1051,18 @@ } ) end end - def menu_for_blocks(menu_options) - options = calculated_options.merge menu_options - menu = [] - iter_blocks_in_file(options) do |btype, fcb| - case btype - when :filter - %i[blocks line] - when :line - if options[:menu_divider_match] && - (mbody = fcb.body[0].match(options[:menu_divider_match])) - menu.push FCB.new({ dname: mbody[:name], oname: mbody[:name], disabled: '' }) - end - when :blocks - menu += [fcb.oname] - end - end - menu + def menu_help + @option_parser.help end def menu_iter(data = menu_for_optparse, &block) data.map(&block) end - def menu_help - @option_parser.help - end - def menu_option_append(opts, options, item) return unless item[:long_name].present? || item[:short_name].present? opts.on(*[ # - long name @@ -929,31 +1086,10 @@ end } ].compact) end - ## post-parse options configuration - # - def options_finalize(rest) - ## position 0: file or folder (optional) - # - if (pos = rest.shift)&.present? - if Dir.exist?(pos) - @options[:path] = pos - elsif File.exist?(pos) - @options[:filename] = pos - else - raise FileMissingError, pos, caller - end - end - - ## position 1: block name (optional) - # - block_name = rest.shift - @options[:block_name] = block_name if block_name.present? - end - # :reek:ControlParameter def optsmerge(call_options = {}, options_block = nil) class_call_options = @options.merge(call_options || {}) if options_block options_block.call class_call_options @@ -993,67 +1129,116 @@ execute_started_at: @execute_started_at, execute_script_filespec: @execute_script_filespec } end - ## insert back option at head or tail + # Prepare the blocks menu by adding labels and other necessary details. # - ## Adds a back option at the head or tail of a menu - # - def prompt_menu_add_back(items, label = BACK_OPTION) - return items unless @options[:menu_with_back] + # @param blocks_in_file [Array<Hash>] The list of blocks from the file. + # @param opts [Hash] The options hash. + # @return [Array<Hash>] The updated blocks menu. + def prepare_blocks_menu(blocks_in_file, opts) + # next if fcb.fetch(:disabled, false) + # next unless fcb.fetch(:name, '').present? + blocks_in_file.map do |fcb| + fcb.merge!( + name: fcb.dname, + label: BlockLabel.make( + body: fcb[:body], + filename: opts[:filename], + headings: fcb.fetch(:headings, []), + menu_blocks_with_docname: opts[:menu_blocks_with_docname], + menu_blocks_with_headings: opts[:menu_blocks_with_headings], + text: fcb[:text], + title: fcb[:title] + ) + ) + fcb.to_h + end.compact + end - history = ENV.fetch('MDE_MENU_HISTORY', '') - return items unless history.present? + # Prepares and fetches file listings + def prepare_file_list(options) + list_files_per_options(options) + end - @hs_curr, @hs_rest = split_string_on_first_char( - history, - @options[:history_document_separator] - ) + def process_fenced_block(fcb, opts, selected_messages, &block) + fcb.oname = fcb.dname = fcb.title || '' + return unless fcb.body - @options[:menu_back_at_top] ? [label] + items : items + [label] + set_fcb_title(fcb) + + if block && + selected_messages.include?(:blocks) && + Filter.fcb_select?(opts, fcb) + block.call :blocks, fcb + end end - ## insert exit option at head or tail + def process_line(line, _opts, selected_messages, &block) + return unless block && selected_messages.include?(:line) + + # dp 'text outside of fcb' + fcb = FCB.new + fcb.body = [line] + block.call(:line, fcb) + end + + ## + # Presents a menu to the user for approving an action and performs additional tasks based on the selection. + # The function provides options for approval, rejection, copying data to clipboard, or saving data to a file. # - def prompt_menu_add_exit(items, label = EXIT_OPTION) - if @options[:menu_exit_at_top] - (@options[:menu_with_exit] ? [label] : []) + items - else - items + (@options[:menu_with_exit] ? [label] : []) + # @param opts [Hash] A hash containing various options for the menu. + # @param required_lines [Array<String>] Lines of text or code that are subject to user approval. + # + # @option opts [String] :prompt_approve_block Prompt text for the approval menu. + # @option opts [String] :prompt_yes Text for the 'Yes' choice in the menu. + # @option opts [String] :prompt_no Text for the 'No' choice in the menu. + # @option opts [String] :prompt_script_to_clipboard Text for the 'Copy to Clipboard' choice in the menu. + # @option opts [String] :prompt_save_script Text for the 'Save to File' choice in the menu. + # + # @return [Boolean] Returns true if the user approves (selects 'Yes'), false otherwise. + ## + def prompt_for_user_approval(opts, required_lines) + # Present a selection menu for user approval. + sel = @prompt.select(opts[:prompt_approve_block], filter: true) do |menu| + menu.default 1 + menu.choice opts[:prompt_yes], 1 + menu.choice opts[:prompt_no], 2 + menu.choice opts[:prompt_script_to_clipboard], 3 + menu.choice opts[:prompt_save_script], 4 end + + if sel == 3 + copy_to_clipboard(required_lines) + elsif sel == 4 + save_to_file(opts, required_lines) + end + + sel == 1 end - ## tty prompt to select - # insert exit option at head or tail - # return selected option or nil + ## insert back option at head or tail # - def prompt_with_quit(prompt_text, items, opts = {}) - obj = prompt_with_quit2(prompt_text, items, opts) - if obj.fetch(:option, nil) - nil - else - obj[:selected] - end + ## Adds a back option at the head or tail of a menu + # + def prompt_menu_add_back(items, label) + return items unless @options[:menu_with_back] && history_state_exist? + + state = history_state_partition(@options) + @hs_curr = state[:unit] + @hs_rest = state[:rest] + @options[:menu_back_at_top] ? [label] + items : items + [label] end - ## tty prompt to select - # insert exit option at head or tail - # return option:, selected option: + ## insert exit option at head or tail # - def prompt_with_quit2(prompt_text, items, opts = {}) - sel = @prompt.select(prompt_text, - prompt_menu_add_exit( - prompt_menu_add_back(items) - ), - opts.merge(filter: true)) - if sel == BACK_OPTION - { option: sel, curr: @hs_curr, rest: @hs_rest } - elsif sel == EXIT_OPTION - { option: sel } + def prompt_menu_add_exit(items, label) + if @options[:menu_exit_at_top] + (@options[:menu_with_exit] ? [label] : []) + items else - { selected: sel } + items + (@options[:menu_with_exit] ? [label] : []) end end # :reek:UtilityFunction ### temp def read_configuration_file!(options, configuration_path) @@ -1061,119 +1246,63 @@ options.merge!((YAML.load(File.open(configuration_path)) || {}) .transform_keys(&:to_sym)) end - # :reek:NestedIterators - def run - @options = base_options + # Reads required code blocks from a temporary file specified by an environment variable. + # + # @return [Array<String>] An array containing the lines read from the temporary file. + # @note Relies on the 'MDE_LINK_REQUIRED_FILE' environment variable to locate the file. + def read_required_blocks_from_temp_file + temp_blocks = [] - read_configuration_file! @options, - ".#{MarkdownExec::APP_NAME.downcase}.yml" + temp_blocks_file_path = ENV.fetch('MDE_LINK_REQUIRED_FILE', nil) + return temp_blocks if temp_blocks_file_path.nil? || temp_blocks_file_path.empty? - @option_parser = option_parser = OptionParser.new do |opts| - executable_name = File.basename($PROGRAM_NAME) - opts.banner = [ - "#{MarkdownExec::APP_NAME}" \ - " - #{MarkdownExec::APP_DESC} (#{MarkdownExec::VERSION})", - "Usage: #{executable_name} [(path | filename [block_name])] [options]" - ].join("\n") - - menu_iter do |item| - menu_option_append opts, options, item - end + if File.exist?(temp_blocks_file_path) + temp_blocks = File.readlines(temp_blocks_file_path, chomp: true) end - option_parser.load - option_parser.environment - rest = option_parser.parse!(arguments_for_mde) # (into: options) - # pass through arguments excluded from OptionParser with `--` - @options[:pass_args] = ARGV[rest.count + 1..] + temp_blocks + end - begin - options_finalize rest - exec_block options, options[:block_name] - rescue FileMissingError => err - puts "File missing: #{err}" - end + def run + clear_required_file + execute_block_with_error_handling(initialize_and_parse_cli_options) + delete_required_temp_file rescue StandardError => err warn(error = "ERROR ** MarkParse.run(); #{err.inspect}") binding.pry if $tap_enable raise ArgumentError, error end - def saved_name_split(name) - # rubocop:disable Layout/LineLength - mf = /#{@options[:saved_script_filename_prefix]}_(?<time>[0-9\-]+)_(?<file>.+)_,_(?<block>.+)\.sh/.match name - # rubocop:enable Layout/LineLength - return unless mf - - @options[:block_name] = mf[:block] - @options[:filename] = mf[:file].gsub(FNR12, FNR11) - end - def run_last_script filename = SavedFilesMatcher.most_recent(@options[:saved_script_folder], @options[:saved_script_glob]) return unless filename saved_name_split filename @options[:save_executed_script] = false select_approve_and_execute_block({}) end - def save_execution_output - return unless @options[:save_execution_output] + def save_to_file(opts, required_lines) + write_command_file(opts.merge(save_executed_script: true), + required_lines) + fout "File saved: #{@options[:saved_filespec]}" + end - @options[:logged_stdout_filename] = - SavedAsset.stdout_name(blockname: @options[:block_name], - filename: File.basename(@options[:filename], '.*'), - prefix: @options[:logged_stdout_filename_prefix], - time: Time.now.utc) + def saved_name_split(name) + # rubocop:disable Layout/LineLength + mf = /#{@options[:saved_script_filename_prefix]}_(?<time>[0-9\-]+)_(?<file>.+)_,_(?<block>.+)\.sh/.match name + # rubocop:enable Layout/LineLength + return unless mf - @options[:logged_stdout_filespec] = - File.join @options[:saved_stdout_folder], - @options[:logged_stdout_filename] - @logged_stdout_filespec = @options[:logged_stdout_filespec] - (dirname = File.dirname(@options[:logged_stdout_filespec])) - FileUtils.mkdir_p dirname - - ol = ["-STDOUT-\n"] - ol += @execute_files&.fetch(EF_STDOUT, []) - ol += ["\n-STDERR-\n"] - ol += @execute_files&.fetch(EF_STDERR, []) - ol += ["\n-STDIN-\n"] - ol += @execute_files&.fetch(EF_STDIN, []) - ol += ["\n"] - File.write(@options[:logged_stdout_filespec], ol.join) + @options[:block_name] = mf[:block] + @options[:filename] = mf[:file].gsub(FNR12, FNR11) end - # Prepare the blocks menu by adding labels and other necessary details. - # - # @param blocks_in_file [Array<Hash>] The list of blocks from the file. - # @param opts [Hash] The options hash. - # @return [Array<Hash>] The updated blocks menu. - def prepare_blocks_menu(blocks_in_file, opts) - # next if fcb.fetch(:disabled, false) - # next unless fcb.fetch(:name, '').present? - blocks_in_file.map do |fcb| - fcb.merge!( - name: fcb.dname, - label: BlockLabel.make( - body: fcb[:body], - filename: opts[:filename], - headings: fcb.fetch(:headings, []), - menu_blocks_with_docname: opts[:menu_blocks_with_docname], - menu_blocks_with_headings: opts[:menu_blocks_with_headings], - text: fcb[:text], - title: fcb[:title] - ) - ) - fcb.to_h - end.compact - 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. # @@ -1181,63 +1310,37 @@ # @param options_block [Block] Block of options to be merged with call_options. # @return [Nil] Returns nil if no code block is selected or an error occurs. def select_approve_and_execute_block(call_options, &options_block) opts = optsmerge(call_options, options_block) repeat_menu = true && !opts[:block_name].present? - load_file = !LOAD_FILE + default = 1 + loop do - # load file - # loop do - # repeat menu - # - load_file = !LOAD_FILE - blocks_in_file = list_blocks_in_file(opts.merge(struct: true)) - mdoc = MDoc.new(blocks_in_file) do |nopts| - opts.merge!(nopts) - end - blocks_menu = mdoc.fcbs_per_options(opts.merge(struct: true)) - unless opts[:block_name].present? - pt = opts[:prompt_select_block].to_s - bm = prepare_blocks_menu(blocks_menu, opts) - return nil if bm.count.zero? + opts[:back] = false + blocks_in_file, blocks_menu, mdoc = load_file_and_prepare_menu(opts) - # sel = prompt_with_quit(pt, bm, per_page: opts[:select_page_height]) - # return nil if sel.nil? - obj = prompt_with_quit2(pt, bm, per_page: opts[:select_page_height]) - case obj.fetch(:option, nil) - when EXIT_OPTION + unless opts[:block_name].present? + block_name, state = wait_for_user_selection(blocks_in_file, blocks_menu, default, + opts) + case state + when :exit return nil - when BACK_OPTION - opts[:filename] = obj[:curr] - opts[:block_name] = @options[:block_name] = '' - ENV['MDE_MENU_HISTORY'] = obj[:rest] - load_file = LOAD_FILE # later: exit menu, load file - else - sel = obj[:selected] - - ## store selected option - # - label_block = blocks_in_file.select do |fcb| - fcb.dname == sel - end.fetch(0, nil) - opts[:block_name] = @options[:block_name] = label_block.oname + when :back + opts[:block_name] = block_name[:option] + opts[:back] = true + when :continue + opts[:block_name] = block_name end end - break if load_file == LOAD_FILE - # later: load file + load_file, next_block_name = approve_and_execute_block(opts, mdoc) + default = load_file == LOAD_FILE ? 1 : opts[:block_name] + opts[:block_name] = next_block_name - load_file, block_name = approve_and_execute_block(opts, mdoc) - - opts[:block_name] = block_name - if load_file == LOAD_FILE - repeat_menu = true - break - end - + break if state == :continue && load_file == LOAD_FILE break unless repeat_menu end break if load_file != LOAD_FILE end rescue StandardError => err @@ -1245,44 +1348,72 @@ warn err.backtrace binding.pry if $tap_enable raise ArgumentError, error end - def select_md_file(files = list_markdown_files_in_path) - opts = options - if (count = files.count) == 1 - files[0] - elsif count >= 2 - prompt_with_quit opts[:prompt_select_md].to_s, files, - per_page: opts[:select_page_height] + def select_document_if_multiple(files = list_markdown_files_in_path) + return files[0] if (count = files.count) == 1 + + return unless count >= 2 + + opts = options.dup + select_option_or_exit opts[:prompt_select_md].to_s, files, + opts.merge(per_page: opts[:select_page_height]) + end + + # Presents a TTY prompt to select an option or exit, returns selected option or nil + def select_option_or_exit(prompt_text, items, opts = {}) + result = select_option_with_metadata(prompt_text, items, opts) + return unless result.fetch(:option, nil) + + result[:selected] + end + + # Presents a TTY prompt to select an option or exit, returns metadata including option and selected + def select_option_with_metadata(prompt_text, items, opts = {}) + selection = @prompt.select(prompt_text, + prompt_menu_add_exit( + prompt_menu_add_back( + items, + opts[:menu_option_back_name] + ), + opts[:menu_option_exit_name] + ), + opts.merge(filter: true)) + if selection == opts[:menu_option_back_name] + { option: selection, curr: @hs_curr, rest: @hs_rest, shell: BLOCK_TYPE_LINK } + elsif selection == opts[:menu_option_exit_name] + { option: selection } + else + { selected: selection } end end def select_recent_output - filename = prompt_with_quit( + filename = select_option_or_exit( @options[:prompt_select_output].to_s, list_recent_output( @options[:saved_stdout_folder], @options[:saved_stdout_glob], @options[:list_count] ), - { per_page: @options[:select_page_height] } + @options.merge({ per_page: @options[:select_page_height] }) ) return unless filename.present? `open #{filename} #{options[:output_viewer_options]}` end def select_recent_script - filename = prompt_with_quit( + filename = select_option_or_exit( @options[:prompt_select_md].to_s, list_recent_scripts( @options[:saved_script_folder], @options[:saved_script_glob], @options[:list_count] ), - { per_page: @options[:select_page_height] } + @options.merge({ per_page: @options[:select_page_height] }) ) return if filename.nil? saved_name_split(filename) @@ -1291,39 +1422,120 @@ save_executed_script: false, struct: true }) end - def menu_export(data = menu_for_optparse) - data.map do |item| - item.delete(:procname) - item - end.to_yaml + # set the title of an FCB object based on its body if it is nil or empty + def set_fcb_title(fcb) + return unless fcb.title.nil? || fcb.title.empty? + + fcb.title = (fcb&.body || []).join(' ').gsub(/ +/, ' ')[0..64] end - # Splits the given string on the first occurrence of the specified character. - # Returns an array containing the portion of the string before the character and the rest of the string. - # - # @param input_str [String] The string to be split. - # @param split_char [String] The character on which to split the string. - # @return [Array<String>] An array containing two elements: the part of the string before split_char, and the rest of the string. - def split_string_on_first_char(input_str, split_char) - split_index = input_str.index(split_char) + def start_fenced_block(opts, line, headings, fenced_start_extended_regex) + fcb_title_groups = line.match(fenced_start_extended_regex).named_captures.sym_keys + rest = fcb_title_groups.fetch(:rest, '') - if split_index.nil? - [input_str, ''] - else - [input_str[0...split_index], input_str[(split_index + 1)..-1]] + fcb = FCB.new + fcb.headings = headings + fcb.oname = fcb.dname = fcb_title_groups.fetch(:name, '') + fcb.shell = fcb_title_groups.fetch(:shell, '') + fcb.title = fcb_title_groups.fetch(:name, '') + fcb.body = [] + fcb.reqs, fcb.wraps = + ArrayUtil.partition_by_predicate(rest.scan(/\+[^\s]+/).map do |req| + req[1..-1] + end) do |name| + !name.match(Regexp.new(opts[:block_name_wrapper_match])) end + fcb.call = rest.match(Regexp.new(opts[:block_calls_scan]))&.to_a&.first + fcb.stdin = if (tn = rest.match(/<(?<type>\$)?(?<name>[A-Za-z_-]\S+)/)) + tn.named_captures.sym_keys + end + fcb.stdout = if (tn = rest.match(/>(?<type>\$)?(?<name>[A-Za-z_\-.\w]+)/)) + tn.named_captures.sym_keys + end + fcb end def tab_completions(data = menu_for_optparse) data.map do |item| "--#{item[:long_name]}" if item[:long_name] end.compact end + def tty_prompt_without_disabled_symbol + TTY::Prompt.new(interrupt: :exit, symbols: { cross: ' ' }) + end + + ## + # Updates the hierarchy of document headings based on the given line and existing headings. + # The function uses regular expressions specified in the `opts` to identify different levels of headings. + # + # @param line [String] The line of text to examine for heading content. + # @param headings [Array<String>] The existing list of document headings. + # @param opts [Hash] A hash containing options for regular expression matches for different heading levels. + # + # @option opts [String] :heading1_match Regular expression for matching first-level headings. + # @option opts [String] :heading2_match Regular expression for matching second-level headings. + # @option opts [String] :heading3_match Regular expression for matching third-level headings. + # + # @return [Array<String>] Updated list of headings. + def update_document_headings(line, headings, opts) + if (lm = line.match(Regexp.new(opts[:heading3_match]))) + [headings[0], headings[1], lm[:name]] + elsif (lm = line.match(Regexp.new(opts[:heading2_match]))) + [headings[0], lm[:name]] + elsif (lm = line.match(Regexp.new(opts[:heading1_match]))) + [lm[:name]] + else + headings + end + end + + ## + # Processes an individual line within a loop, updating headings and handling fenced code blocks. + # 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 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. + ## + def update_line_and_block_state(line, state, opts, selected_messages, &block) + if opts[:menu_blocks_with_headings] + state[:headings] = + update_document_headings(line, state[:headings], opts) + end + + if line.match(state[:fenced_start_and_end_regex]) + if state[:in_fenced_block] + process_fenced_block(state[:fcb], opts, selected_messages, &block) + state[:in_fenced_block] = false + else + state[:fcb] = + start_fenced_block(opts, line, state[:headings], + state[:fenced_start_extended_regex]) + state[:in_fenced_block] = true + end + elsif state[:in_fenced_block] && state[:fcb].body + state[:fcb].body += [line.chomp] + else + process_line(line, opts, selected_messages, &block) + end + end + # :reek:BooleanParameter # :reek:ControlParameter def update_options(opts = {}, over: true) if over @options = @options.merge opts @@ -1331,10 +1543,33 @@ @options.merge! opts end @options end + ## Handles the menu interaction and returns selected block name and option state + # + def wait_for_user_selection(blocks_in_file, blocks_menu, default, opts) + pt = opts[:prompt_select_block].to_s + bm = prepare_blocks_menu(blocks_menu, opts) + return [nil, :exit] if bm.count.zero? + + obj = select_option_with_metadata(pt, bm, opts.merge( + default: default, + per_page: opts[:select_page_height] + )) + case obj.fetch(:option, nil) + when opts[:menu_option_exit_name] + [nil, :exit] + when opts[:menu_option_back_name] + [obj, :back] + else + label_block = blocks_in_file.find { |fcb| fcb.dname == obj[:selected] } + [label_block.oname, :continue] + end + end + + # Handles the core logic for generating the command file's metadata and content. def write_command_file(call_options, required_lines) return unless call_options[:save_executed_script] time_now = Time.now.utc opts = optsmerge call_options @@ -1346,27 +1581,57 @@ @execute_script_filespec = @options[:saved_filespec] = File.join opts[:saved_script_folder], opts[:saved_script_filename] - dirname = File.dirname(@options[:saved_filespec]) - FileUtils.mkdir_p dirname shebang = if @options[:shebang]&.present? "#{@options[:shebang]} #{@options[:shell]}\n" else '' end - File.write(@options[:saved_filespec], shebang + - "# file_name: #{opts[:filename]}\n" \ - "# block_name: #{opts[:block_name]}\n" \ - "# time: #{time_now}\n" \ - "#{required_lines.flatten.join("\n")}\n") - return if @options[:saved_script_chmod].zero? + content = shebang + + "# file_name: #{opts[:filename]}\n" \ + "# block_name: #{opts[:block_name]}\n" \ + "# time: #{time_now}\n" \ + "#{required_lines.flatten.join("\n")}\n" - File.chmod @options[:saved_script_chmod], @options[:saved_filespec] + create_and_write_file_with_permissions(@options[:saved_filespec], content, + @options[:saved_script_chmod]) end + + def write_execution_output_to_file + FileUtils.mkdir_p File.dirname(@options[:logged_stdout_filespec]) + + ol = ["-STDOUT-\n"] + ol += @execute_files&.fetch(EF_STDOUT, []) + ol += ["\n-STDERR-\n"] + ol += @execute_files&.fetch(EF_STDERR, []) + ol += ["\n-STDIN-\n"] + ol += @execute_files&.fetch(EF_STDIN, []) + ol += ["\n"] + File.write(@options[:logged_stdout_filespec], ol.join) + end + + # Writes required code blocks to a temporary file and sets an environment variable with its path. + # + # @param block_name [String] The name of the block to collect code for. + # @param opts [Hash] Additional options for collecting code. + # @note Sets the 'MDE_LINK_REQUIRED_FILE' environment variable to the temporary file path. + def write_required_blocks_to_temp_file(mdoc, block_name, opts = {}) + code_blocks = (read_required_blocks_from_temp_file + + mdoc.collect_recursively_required_code( + block_name, + opts: opts + )[:code]).join("\n") + + Dir::Tmpname.create(self.class.to_s) do |path| + pp path + File.write(path, code_blocks) + ENV['MDE_LINK_REQUIRED_FILE'] = path + end + end end # class MarkParse end # module MarkdownExec if $PROGRAM_NAME == __FILE__ require 'bundler/setup' @@ -1386,11 +1651,12 @@ # Expect that method command_execute is called with argument args having value pigeon c.expects(:command_execute).with( obj, '', - args: pigeon) + args: pigeon + ) # Call method execute_approved_block c.execute_approved_block(obj, []) end @@ -1413,24 +1679,17 @@ input: FCB.new(title: 'foo', body: %w[bar baz]), output: 'foo' # expect the title to remain unchanged } ] - # iterate over the input and output data and assert that the method sets the title as expected + # iterate over the input and output data and + # assert that the method sets the title as expected input_output_data.each do |data| input = data[:input] output = data[:output] @mark_parse.set_fcb_title(input) assert_equal output, input.title end end end - - ### - - # result = split_string_on_first_char("hello-world", "-") - # puts result.inspect # Output should be ["hello", "world"] - - # result = split_string_on_first_char("hello", "-") - # puts result.inspect # Output should be ["hello", ""] end end