lib/markdown_exec.rb in markdown_exec-0.0.5 vs lib/markdown_exec.rb in markdown_exec-0.0.6

- old
+ new

@@ -1,14 +1,536 @@ # frozen_string_literal: true require_relative 'markdown_exec/version' +#!/usr/bin/env ruby +# frozen_string_literal: true + +# encoding=utf-8 +# rubocop:disable Style/GlobalVars + +env_debug = ENV['MARKDOWN_EXEC_DEBUG'] +$pdebug = !(env_debug || '').empty? + +# APP_NAME = 'MDExec' +# APP_DESC = 'Markdown block executor' +# VERSION = '0.0.6' + +# require 'markdown_exec' +# # puts MarkdownExec::MarkParse.echo(ARGV[0]) + +require 'optparse' +require 'pathname' +require 'tty-prompt' +require 'yaml' + +# require_relative 'mdlib' +# #!/usr/bin/env ruby +# # frozen_string_literal: true +# # encoding=utf-8 + +# env_debug = ENV['MARKDOWN_EXEC_DEBUG'] +# $pdebug = !(env_debug || '').empty? + +require 'open3' +# require 'tty-prompt' + +BLOCK_SIZE = 1024 +SELECT_PAGE_HEIGHT = 12 + module MarkdownExec class Error < StandardError; end # Markdown Exec - class MDExec - def self.echo(str = '') - "#{str}#{str}" + # class MarkParse + # def self.echo(str = '') + # "#{str}#{str}" + # end + # end + ## + # + class MarkParse + attr_accessor :options + + def initialize(options = {}) + @options = options end + + def count_blocks + cnt = 0 + File.readlines(options[:mdfilename]).each do |line| + cnt += 1 if line.match(/^```/) + end + cnt / 2 + end + + def find_files + puts "pwd: #{`pwd`}" if $pdebug + # `ls -1 *.md`.split("\n").tap { |ret| puts "find_files() ret: #{ret.inspect}" if $pdebug } + `ls -1 #{File.join options[:mdfolder], '*.md'}`.split("\n").tap do |ret| + puts "find_files() ret: #{ret.inspect}" if $pdebug + end + end + + def fout(str) + puts str # to stdout + end + + def copts(call_options = {}, options_block = nil) + class_call_options = options.merge(call_options || {}) + if options_block + options_block.call class_call_options + else + class_call_options + end.tap { |ret| puts "copts() ret: #{ret.inspect}" if $pdebug } + end + + def bsr(headings, title) + # puts "bsr() headings: #{headings.inspect}" + { headings: headings, name: title, title: title } + end + + def block_summary(opts, headings, block_title, current) + puts "block_summary() block_title: #{block_title.inspect}" if $pdebug + return [current] unless opts[:struct] + + # return [{ body: current, name: block_title, title: block_title }] unless opts[:bash] + return [bsr(headings, block_title).merge({ body: current })] unless opts[:bash] + + bm = block_title.match(/:(\S+)( |$)/) + reqs = block_title.scan(/\+\S+/).map { |s| s[1..] } + + if $pdebug + puts ["block_summary() bm: #{bm.inspect}", + "block_summary() reqs: #{reqs.inspect}"] + end + + if bm && bm[1] + # [{ body: current, name: bm[1], reqs: reqs, title: bm[1] }] + [bsr(headings, bm[1]).merge({ body: current, reqs: reqs })] + else + # [{ body: current, name: block_title, reqs: reqs, title: block_title }] + [bsr(headings, block_title).merge({ body: current, reqs: reqs })] + end + end + + def get_blocks(call_options = {}, &options_block) + opts = copts call_options, options_block + + blocks = [] + current = nil + in_block = false + block_title = '' + + headings = [] + File.readlines(opts[:mdfilename]).each do |line| + puts "get_blocks() line: #{line.inspect}" if $pdebug + continue unless line + + if opts[:mdheadings] + if (lm = line.match(/^### *(.+?) *$/)) + headings = [headings[0], headings[1], lm[1]] + elsif (lm = line.match(/^## *([^#]*?) *$/)) + headings = [headings[0], lm[1]] + elsif (lm = line.match(/^# *([^#]*?) *$/)) + headings = [lm[1]] + end + puts "get_blocks() headings: #{headings.inspect}" if $pdebug + end + + if line.match(/^`{3,}/) + if in_block + puts 'get_blocks() in_block' if $pdebug + if current + + # block_title ||= current.join(' ').gsub(/ +/, ' ')[0..64] + block_title = current.join(' ').gsub(/ +/, ' ')[0..64] if block_title.nil? || block_title.empty? + + blocks += block_summary opts, headings, block_title, current + current = nil + end + in_block = false + block_title = '' + else + ## new block + # + + # lm = line.match(/^`{3,}([^`\s]+)( .+)?$/) + lm = line.match(/^`{3,}([^`\s]*) *(.*)$/) + + do1 = false + if opts[:bash_only] + do1 = true if lm && (lm[1] == 'bash') + elsif opts[:exclude_expect_blocks] + do1 = true unless lm && (lm[1] == 'expect') + else + do1 = true + end + if $pdebug + puts ["get_blocks() lm: #{lm.inspect}", + "get_blocks() opts: #{opts.inspect}", + "get_blocks() do1: #{do1}"] + end + + if do1 && (!opts[:title_match] || (lm && lm[2] && lm[2].match(opts[:title_match]))) + current = [] + in_block = true + block_title = (lm && lm[2]) + end + + end + elsif current + current += [line.chomp] + end + end + blocks.tap { |ret| puts "get_blocks() ret: #{ret.inspect}" if $pdebug } + end + + def make_block_label(block, call_options = {}) + opts = options.merge(call_options) + puts "make_block_label() opts: #{opts.inspect}" if $pdebug + puts "make_block_label() block: #{block.inspect}" if $pdebug + if opts[:mdheadings] + heads = block.fetch(:headings, []).compact.join(' # ') + "#{block[:title]} [#{heads}] (#{opts[:mdfilename]})" + else + "#{block[:title]} (#{opts[:mdfilename]})" + end + end + + def make_block_labels(call_options = {}) + opts = options.merge(call_options) + get_blocks(opts).map do |block| + make_block_label block, opts + end + end + + def select_block(call_options = {}, &options_block) + opts = copts call_options, options_block + + blocks = get_blocks(opts.merge(struct: true)) + puts "select_block() blocks: #{blocks.to_yaml}" if $pdebug + + prompt = TTY::Prompt.new(interrupt: :exit) + pt = "#{opts.fetch(:prompt, nil) || 'Pick one'}:" + puts "select_block() pt: #{pt.inspect}" if $pdebug + + blocks.each { |block| block.merge! label: make_block_label(block, opts) } + block_labels = blocks.map { |block| block[:label] } + puts "select_block() block_labels: #{block_labels.inspect}" if $pdebug + + if opts[:preview_options] + select_per_page = 3 + block_labels.each do |bn| + fout " - #{bn}" + end + else + select_per_page = SELECT_PAGE_HEIGHT + end + + return nil if block_labels.count.zero? + + sel = prompt.select(pt, block_labels, per_page: select_per_page) + puts "select_block() sel: #{sel.inspect}" if $pdebug + # catch + # # catch TTY::Reader::InputInterrupt + # puts "InputInterrupt" + # end + + label_block = blocks.select { |block| block[:label] == sel }.fetch(0, nil) + puts "select_block() label_block: #{label_block.inspect}" if $pdebug + sel = label_block[:name] + puts "select_block() sel: #{sel.inspect}" if $pdebug + + cbs = code_blocks(blocks, sel) + puts "select_block() cbs: #{cbs.inspect}" if $pdebug + + ## display code blocks for approval + # + cbs.each { |cb| fout cb } if opts[:display] || opts[:approve] + + allow = true + allow = prompt.yes? 'Process?' if opts[:approve] + + selected = block_by_name blocks, sel + puts "select_block() selected: #{selected.inspect}" if $pdebug + if allow && opts[:execute] + + ## process in script, to handle line continuations + # + cmd2 = cbs.flatten.join("\n") + fout "$ #{cmd2.to_yaml}" + + # Open3.popen3(cmd2) do |stdin, stdout, stderr, wait_thr| + # cnt += 1 + # # stdin.puts "This is sent to the command" + # # stdin.close # we're done + # stdout_str = stdout.read # read stdout to string. note that this will block until the command is done! + # stderr_str = stderr.read # read stderr to string + # status = wait_thr.value # will block until the command finishes; returns status that responds to .success? + # fout "#{stdout_str}" + # fout "#{cnt}: err: #{stderr_str}" if stderr_str != '' + # # fout "#{cnt}: stt: #{status}" + # end + + Open3.popen3(cmd2) do |stdin, stdout, stderr| + stdin.close_write + begin + files = [stdout, stderr] + + until all_eof(files) + ready = IO.select(files) + + next unless ready + + readable = ready[0] + # writable = ready[1] + # exceptions = ready[2] + + readable.each do |f| + # fileno = f.fileno + + data = f.read_nonblock(BLOCK_SIZE) + # fout "- fileno: #{fileno}\n#{data}" + fout data + rescue EOFError #=> e + # fout "fileno: #{fileno} EOF" + end + end + rescue IOError => e + fout "IOError: #{e}" + end + end + end + + selected[:name] + end + + def select_md_file + opts = options + files = find_files + if files.count == 1 + sel = files[0] + elsif files.count >= 2 + + if opts[:preview_options] + select_per_page = 3 + files.each do |file| + fout " - #{file}" + end + else + select_per_page = SELECT_PAGE_HEIGHT + end + + prompt = TTY::Prompt.new + sel = prompt.select("#{opts.fetch(:prompt, 'Pick one')}:", files, per_page: select_per_page) + end + + sel + end + + # Returns true if all files are EOF + # + def all_eof(files) + files.find { |f| !f.eof }.nil? + end + + def code(table, block) + puts "code() table: #{table.inspect}" if $pdebug + puts "code() block: #{block.inspect}" if $pdebug + all = [block[:name]] + unroll(table, block[:reqs]) + puts "code() all: #{all.inspect}" if $pdebug + all.reverse.map do |req| + puts "code() req: #{req.inspect}" if $pdebug + block_by_name(table, req).fetch(:body, '') + end + .flatten(1) + .tap { |ret| puts "code() ret: #{ret.inspect}" if $pdebug } + end + + def block_by_name(table, name, default = {}) + table.select { |block| block[:name] == name }.fetch(0, default) + end + + def code_blocks(table, name) + puts "code_blocks() table: #{table.inspect}" if $pdebug + puts "code_blocks() name: #{name.inspect}" if $pdebug + name_block = block_by_name(table, name) + puts "code_blocks() name_block: #{name_block.inspect}" if $pdebug + all = [name_block[:name]] + unroll(table, name_block[:reqs]) + puts "code_blocks() all: #{all.inspect}" if $pdebug + + # in order of appearance in document + table.select { |block| all.include? block[:name] } + .map { |block| block.fetch(:body, '') } + .flatten(1) + .tap { |ret| puts "code_blocks() ret: #{ret.inspect}" if $pdebug } + end + + def unroll(table, reqs) + puts "unroll() table: #{table.inspect}" if $pdebug + puts "unroll() reqs: #{reqs.inspect}" if $pdebug + all = [] + rem = reqs + while rem.count.positive? + puts "unrol() rem: #{rem.inspect}" if $pdebug + rem = rem.map do |req| + puts "unrol() req: #{req.inspect}" if $pdebug + next if all.include? req + + all += [req] + puts "unrol() all: #{all.inspect}" if $pdebug + block_by_name(table, req).fetch(:reqs, []) + end + .compact + .flatten(1) + .tap { |_ret| puts "unroll() rem: #{rem.inspect}" if $pdebug } + end + all.tap { |ret| puts "unroll() ret: #{ret.inspect}" if $pdebug } + end end + + # $stderr.sync = true + # $stdout.sync = true + + def fout(str) + puts str # to stdout + end + + ## configuration file + # + def read_configuration!(options, configuration_path) + if Pathname.new(configuration_path).exist? + # rubocop:disable Security/YAMLLoad + options.merge!((YAML.load(File.open(configuration_path)) || {}) + .transform_keys(&:to_sym)) + # rubocop:enable Security/YAMLLoad + end + options + end + + def run + ## default configuration + # + options = { + mdheadings: true, + list_blocks: false, + list_docs: false, + mdfilename: 'README.md', + mdfolder: '.' + } + + def options_finalize!(options); end + + # read local configuration file + # + read_configuration! options, ".#{APP_NAME.downcase}.yml" + + ## read current details for aws resources from app_data_file + # + # load_resources! options + # puts "q31 options: #{options.to_yaml}" if $pdebug + + # rubocop:disable Metrics/BlockLength + option_parser = OptionParser.new do |opts| + executable_name = File.basename($PROGRAM_NAME) + opts.banner = [ + "#{APP_NAME} - #{APP_DESC} (#{VERSION})".freeze, + "Usage: #{executable_name} [options]" + ].join("\n") + + ## menu top: on_head appear in reverse order added + # + opts.on('--config PATH', 'Read configuration file') do |value| + read_configuration! options, value + end + + ## menu body: items appear in order added + # + opts.on('-f RELATIVE', '--mdfilename', 'Name of document') do |value| + options[:mdfilename] = value + end + + opts.on('-p PATH', '--mdfolder', 'Path to documents') do |value| + options[:mdfolder] = value + end + + opts.on('--list-blocks', 'List blocks') do |_value| + options[:list_blocks] = true + end + + opts.on('--list-docs', 'List docs in current folder') do |_value| + options[:list_docs] = true + end + + ## menu bottom: items appear in order added + # + opts.on_tail('-h', '--help', 'App help') do |_value| + puts option_parser.help + exit + end + + opts.on_tail('-v', '--version', 'App version') do |_value| + puts VERSION + exit + end + + opts.on_tail('-x', '--exit', 'Exit app') do |_value| + exit + end + + opts.on_tail('-0', 'Show configuration') do |_v| + options_finalize! options + puts options.to_yaml + end + end + # rubocop:enable Metrics/BlockLength + + option_parser.load # filename defaults to basename of the program without suffix in a directory ~/.options + option_parser.environment # env defaults to the basename of the program. + option_parser.parse! # (into: options) + options_finalize! options + + ## process + # + # rubocop:disable Metrics/BlockLength + loop do # once + mp = MarkParse.new options + options.merge!( + { + approve: true, + bash: true, + display: true, + exclude_expect_blocks: true, + execute: true, + prompt: 'Execute', + struct: true + } + ) + + ## show + # + if options[:list_docs] + fout mp.find_files + break + end + + if options[:list_blocks] + fout (mp.find_files.map do |file| + mp.make_block_labels(mdfilename: file, struct: true) + end).flatten(1).to_yaml + break + end + + ## process + # + mp.select_block(bash: true, struct: true) if options[:mdfilename] + + # rubocop:disable Style/BlockComments + # rubocop:enable Style/BlockComments + + break unless false # rubocop:disable Lint/LiteralAsCondition + end + end + # rubocop:enable Metrics/BlockLength + # rubocop:enable Style/GlobalVars end