markdown_exec 0.1.3 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/markdown_exec.rb CHANGED
@@ -3,18 +3,79 @@
3
3
 
4
4
  # encoding=utf-8
5
5
 
6
- # rubocop:disable Style/GlobalVars
7
- $pdebug = !(ENV['MARKDOWN_EXEC_DEBUG'] || '').empty?
8
-
9
6
  require 'open3'
10
7
  require 'optparse'
11
- # require 'pathname'
12
8
  require 'tty-prompt'
13
9
  require 'yaml'
10
+
11
+ ##
12
+ # default if nil
13
+ # false if empty or '0'
14
+ # else true
15
+
16
+ def env_bool(name, default: false)
17
+ return default if (val = ENV[name]).nil?
18
+ return false if val.empty? || val == '0'
19
+
20
+ true
21
+ end
22
+
23
+ def env_int(name, default: 0)
24
+ return default if (val = ENV[name]).nil?
25
+ return default if val.empty?
26
+
27
+ val.to_i
28
+ end
29
+
30
+ def env_str(name, default: '')
31
+ ENV[name] || default
32
+ end
33
+
34
+ $pdebug = env_bool 'MDE_DEBUG'
35
+
14
36
  require_relative 'markdown_exec/version'
15
37
 
38
+ $stderr.sync = true
39
+ $stdout.sync = true
40
+
16
41
  BLOCK_SIZE = 1024
17
- SELECT_PAGE_HEIGHT = 12
42
+
43
+ class Object # rubocop:disable Style/Documentation
44
+ def present?
45
+ self && !blank?
46
+ end
47
+ end
48
+
49
+ class String # rubocop:disable Style/Documentation
50
+ BLANK_RE = /\A[[:space:]]*\z/.freeze
51
+ def blank?
52
+ empty? || BLANK_RE.match?(self)
53
+ end
54
+ end
55
+
56
+ public
57
+
58
+ # debug output
59
+ #
60
+ def tap_inspect(format: nil, name: 'return')
61
+ return self unless $pdebug
62
+
63
+ fn = case format
64
+ when :json
65
+ :to_json
66
+ when :string
67
+ :to_s
68
+ when :yaml
69
+ :to_yaml
70
+ else
71
+ :inspect
72
+ end
73
+
74
+ puts "-> #{caller[0].scan(/in `?(\S+)'$/)[0][0]}()" \
75
+ " #{name}: #{method(fn).call}"
76
+
77
+ self
78
+ end
18
79
 
19
80
  module MarkdownExec
20
81
  class Error < StandardError; end
@@ -26,494 +87,574 @@ module MarkdownExec
26
87
 
27
88
  def initialize(options = {})
28
89
  @options = options
90
+ @prompt = TTY::Prompt.new(interrupt: :exit)
91
+ end
92
+
93
+ ##
94
+ # options necessary to start, parse input, defaults for cli options
95
+
96
+ def base_options
97
+ {
98
+ # commands
99
+ list_blocks: false, # command
100
+ list_docs: false, # command
101
+
102
+ # command options
103
+ filename: env_str('MDE_FILENAME', default: nil), # option Filename to open
104
+ output_execution_summary: env_bool('MDE_OUTPUT_EXECUTION_SUMMARY', default: false), # option
105
+ output_script: env_bool('MDE_OUTPUT_SCRIPT', default: false), # option
106
+ output_stdout: env_bool('MDE_OUTPUT_STDOUT', default: true), # option
107
+ path: env_str('MDE_PATH', default: nil), # option Folder to search for files
108
+ save_executed_script: env_bool('MDE_SAVE_EXECUTED_SCRIPT', default: false), # option
109
+ saved_script_folder: env_str('MDE_SAVED_SCRIPT_FOLDER', default: 'logs'), # option
110
+ user_must_approve: env_bool('MDE_USER_MUST_APPROVE', default: true), # option Pause for user to approve script
111
+
112
+ # configuration options
113
+ block_name_excluded_match: env_str('MDE_BLOCK_NAME_EXCLUDED_MATCH', default: '^\(.+\)$'),
114
+ block_name_match: env_str('MDE_BLOCK_NAME_MATCH', default: ':(?<title>\S+)( |$)'),
115
+ block_required_scan: env_str('MDE_BLOCK_REQUIRED_SCAN', default: '\+\S+'),
116
+ fenced_start_and_end_match: env_str('MDE_FENCED_START_AND_END_MATCH', default: '^`{3,}'),
117
+ fenced_start_ex_match: env_str('MDE_FENCED_START_EX_MATCH', default: '^`{3,}(?<shell>[^`\s]*) *(?<name>.*)$'),
118
+ heading1_match: env_str('MDE_HEADING1_MATCH', default: '^# *(?<name>[^#]*?) *$'),
119
+ heading2_match: env_str('MDE_HEADING2_MATCH', default: '^## *(?<name>[^#]*?) *$'),
120
+ heading3_match: env_str('MDE_HEADING3_MATCH', default: '^### *(?<name>.+?) *$'),
121
+ md_filename_glob: env_str('MDE_MD_FILENAME_GLOB', default: '*.[Mm][Dd]'),
122
+ md_filename_match: env_str('MDE_MD_FILENAME_MATCH', default: '.+\\.md'),
123
+ mdheadings: true, # use headings (levels 1,2,3) in block lable
124
+ select_page_height: env_int('MDE_SELECT_PAGE_HEIGHT', default: 12)
125
+ }
126
+ end
127
+
128
+ def default_options
129
+ {
130
+ bash: true, # bash block parsing in get_block_summary()
131
+ exclude_expect_blocks: true,
132
+ exclude_matching_block_names: true, # exclude hidden blocks
133
+ output_saved_script_filename: false,
134
+ prompt_select_block: 'Choose a block:', # in select_and_approve_block()
135
+ prompt_select_md: 'Choose a file:', # in select_md_file()
136
+ saved_script_filename: nil, # calculated
137
+ struct: true # allow get_block_summary()
138
+ }
29
139
  end
30
140
 
31
- def count_blocks
141
+ # Returns true if all files are EOF
142
+ #
143
+ def all_at_eof(files)
144
+ files.find { |f| !f.eof }.nil?
145
+ end
146
+
147
+ def approve_block(opts, blocks_in_file)
148
+ required_blocks = list_recursively_required_blocks(blocks_in_file, opts[:block_name])
149
+ display_command(opts, required_blocks) if opts[:output_script] || opts[:user_must_approve]
150
+
151
+ allow = true
152
+ allow = @prompt.yes? 'Process?' if opts[:user_must_approve]
153
+ opts[:ir_approve] = allow
154
+ selected = get_block_by_name blocks_in_file, opts[:block_name]
155
+
156
+ if opts[:ir_approve]
157
+ write_command_file(opts, required_blocks) if opts[:save_executed_script]
158
+ command_execute opts, required_blocks.flatten.join("\n")
159
+ end
160
+
161
+ selected[:name]
162
+ end
163
+
164
+ def code(table, block)
165
+ all = [block[:name]] + recursively_required(table, block[:reqs])
166
+ all.reverse.map do |req|
167
+ get_block_by_name(table, req).fetch(:body, '')
168
+ end
169
+ .flatten(1)
170
+ .tap_inspect
171
+ end
172
+
173
+ def command_execute(opts, cmd2)
174
+ @execute_options = opts
175
+ @execute_started_at = Time.now.utc
176
+ Open3.popen3(cmd2) do |stdin, stdout, stderr|
177
+ stdin.close_write
178
+ begin
179
+ files = [stdout, stderr]
180
+
181
+ until all_at_eof(files)
182
+ ready = IO.select(files)
183
+
184
+ next unless ready
185
+
186
+ # readable = ready[0]
187
+ # # writable = ready[1]
188
+ # # exceptions = ready[2]
189
+ @execute_files = Hash.new([])
190
+ ready.each.with_index do |readable, ind|
191
+ readable.each do |f|
192
+ block = f.read_nonblock(BLOCK_SIZE)
193
+ @execute_files[ind] = @execute_files[ind] + [block]
194
+ print block if opts[:output_stdout]
195
+ rescue EOFError #=> e
196
+ # do nothing at EOF
197
+ end
198
+ end
199
+ end
200
+ rescue IOError => e
201
+ fout "IOError: #{e}"
202
+ end
203
+ @execute_completed_at = Time.now.utc
204
+ end
205
+ rescue Errno::ENOENT => e
206
+ @execute_aborted_at = Time.now.utc
207
+ @execute_error_message = e.message
208
+ @execute_error = e
209
+ fout "Error ENOENT: #{e.inspect}"
210
+ end
211
+
212
+ def count_blocks_in_filename
213
+ fenced_start_and_end_match = Regexp.new @options[:fenced_start_and_end_match]
32
214
  cnt = 0
33
- File.readlines(options[:mdfilename]).each do |line|
34
- cnt += 1 if line.match(/^```/)
215
+ File.readlines(@options[:filename]).each do |line|
216
+ cnt += 1 if line.match(fenced_start_and_end_match)
35
217
  end
36
218
  cnt / 2
37
219
  end
38
220
 
39
- def find_files
40
- puts "pwd: #{`pwd`}" if $pdebug
41
- # `ls -1 *.md`.split("\n").tap { |ret| puts "find_files() ret: #{ret.inspect}" if $pdebug }
42
- `ls -1 #{File.join options[:mdfolder], '*.md'}`.split("\n").tap do |ret|
43
- puts "find_files() ret: #{ret.inspect}" if $pdebug
221
+ def display_command(_opts, required_blocks)
222
+ required_blocks.each { |cb| fout cb }
223
+ end
224
+
225
+ def exec_block(options, block_name = '')
226
+ options = default_options.merge options
227
+ update_options options, over: false
228
+
229
+ # document and block reports
230
+ #
231
+ files = list_files_per_options(options)
232
+ if @options[:list_docs]
233
+ fout_list files
234
+ return
235
+ end
236
+
237
+ if @options[:list_blocks]
238
+ fout_list (files.map do |file|
239
+ make_block_labels(filename: file, struct: true)
240
+ end).flatten(1)
241
+ return
44
242
  end
243
+
244
+ # process
245
+ #
246
+ select_and_approve_block(
247
+ bash: true,
248
+ block_name: block_name,
249
+ filename: select_md_file(files),
250
+ struct: true
251
+ )
252
+
253
+ fout "saved_filespec: #{@execute_script_filespec}" if @options[:output_saved_script_filename]
254
+
255
+ output_execution_summary if @options[:output_execution_summary]
45
256
  end
46
257
 
258
+ # standard output; not for debug
259
+ #
47
260
  def fout(str)
48
- puts str # to stdout
261
+ puts str
49
262
  end
50
263
 
51
- def copts(call_options = {}, options_block = nil)
52
- class_call_options = options.merge(call_options || {})
53
- if options_block
54
- options_block.call class_call_options
55
- else
56
- class_call_options
57
- end.tap { |ret| puts "copts() ret: #{ret.inspect}" if $pdebug }
264
+ def fout_list(str)
265
+ puts str
58
266
  end
59
267
 
60
- def bsr(headings, title)
61
- # puts "bsr() headings: #{headings.inspect}"
62
- { headings: headings, name: title, title: title }
268
+ def fout_section(name, data)
269
+ puts "# #{name}"
270
+ puts data.to_yaml
63
271
  end
64
272
 
65
- def block_summary(opts, headings, block_title, current)
66
- puts "block_summary() block_title: #{block_title.inspect}" if $pdebug
273
+ def get_block_by_name(table, name, default = {})
274
+ table.select { |block| block[:name] == name }.fetch(0, default)
275
+ end
276
+
277
+ def get_block_summary(opts, headings, block_title, current)
67
278
  return [current] unless opts[:struct]
68
279
 
69
- # return [{ body: current, name: block_title, title: block_title }] unless opts[:bash]
70
- return [bsr(headings, block_title).merge({ body: current })] unless opts[:bash]
280
+ return [summarize_block(headings, block_title).merge({ body: current })] unless opts[:bash]
71
281
 
72
- bm = block_title.match(/:(\S+)( |$)/)
73
- reqs = block_title.scan(/\+\S+/).map { |s| s[1..] }
74
-
75
- if $pdebug
76
- puts ["block_summary() bm: #{bm.inspect}",
77
- "block_summary() reqs: #{reqs.inspect}"]
78
- end
282
+ bm = block_title.match(Regexp.new(opts[:block_name_match]))
283
+ reqs = block_title.scan(Regexp.new(opts[:block_required_scan])).map { |s| s[1..] }
79
284
 
80
285
  if bm && bm[1]
81
- # [{ body: current, name: bm[1], reqs: reqs, title: bm[1] }]
82
- [bsr(headings, bm[1]).merge({ body: current, reqs: reqs })]
286
+ [summarize_block(headings, bm[:title]).merge({ body: current, reqs: reqs })]
83
287
  else
84
- # [{ body: current, name: block_title, reqs: reqs, title: block_title }]
85
- [bsr(headings, block_title).merge({ body: current, reqs: reqs })]
288
+ [summarize_block(headings, block_title).merge({ body: current, reqs: reqs })]
86
289
  end
87
290
  end
88
291
 
89
- def get_blocks(call_options = {}, &options_block)
90
- opts = copts call_options, options_block
292
+ def list_blocks_in_file(call_options = {}, &options_block)
293
+ opts = optsmerge call_options, options_block
294
+
295
+ unless opts[:filename]&.present?
296
+ fout 'No blocks found.'
297
+ exit 1
298
+ end
91
299
 
300
+ fenced_start_and_end_match = Regexp.new opts[:fenced_start_and_end_match]
301
+ fenced_start_ex = Regexp.new opts[:fenced_start_ex_match]
302
+ block_title = ''
92
303
  blocks = []
93
304
  current = nil
94
- in_block = false
95
- block_title = ''
96
-
97
305
  headings = []
98
- File.readlines(opts[:mdfilename]).each do |line|
99
- puts "get_blocks() line: #{line.inspect}" if $pdebug
306
+ in_block = false
307
+ File.readlines(opts[:filename]).each do |line|
100
308
  continue unless line
101
309
 
102
310
  if opts[:mdheadings]
103
- if (lm = line.match(/^### *(.+?) *$/))
104
- headings = [headings[0], headings[1], lm[1]]
105
- elsif (lm = line.match(/^## *([^#]*?) *$/))
106
- headings = [headings[0], lm[1]]
107
- elsif (lm = line.match(/^# *([^#]*?) *$/))
108
- headings = [lm[1]]
311
+ if (lm = line.match(Regexp.new(opts[:heading3_match])))
312
+ headings = [headings[0], headings[1], lm[:name]]
313
+ elsif (lm = line.match(Regexp.new(opts[:heading2_match])))
314
+ headings = [headings[0], lm[:name]]
315
+ elsif (lm = line.match(Regexp.new(opts[:heading1_match])))
316
+ headings = [lm[:name]]
109
317
  end
110
- puts "get_blocks() headings: #{headings.inspect}" if $pdebug
111
318
  end
112
319
 
113
- if line.match(/^`{3,}/)
320
+ if line.match(fenced_start_and_end_match)
114
321
  if in_block
115
- puts 'get_blocks() in_block' if $pdebug
116
322
  if current
117
-
118
- # block_title ||= current.join(' ').gsub(/ +/, ' ')[0..64]
119
323
  block_title = current.join(' ').gsub(/ +/, ' ')[0..64] if block_title.nil? || block_title.empty?
120
-
121
- blocks += block_summary opts, headings, block_title, current
324
+ blocks += get_block_summary opts, headings, block_title, current
122
325
  current = nil
123
326
  end
124
327
  in_block = false
125
328
  block_title = ''
126
329
  else
127
- ## new block
330
+ # new block
128
331
  #
129
-
130
- # lm = line.match(/^`{3,}([^`\s]+)( .+)?$/)
131
- lm = line.match(/^`{3,}([^`\s]*) *(.*)$/)
132
-
332
+ lm = line.match(fenced_start_ex)
133
333
  do1 = false
134
334
  if opts[:bash_only]
135
- do1 = true if lm && (lm[1] == 'bash')
136
- elsif opts[:exclude_expect_blocks]
137
- do1 = true unless lm && (lm[1] == 'expect')
335
+ do1 = true if lm && (lm[:shell] == 'bash')
138
336
  else
139
337
  do1 = true
140
- end
141
- if $pdebug
142
- puts ["get_blocks() lm: #{lm.inspect}",
143
- "get_blocks() opts: #{opts.inspect}",
144
- "get_blocks() do1: #{do1}"]
338
+ do1 = !(lm && (lm[:shell] == 'expect')) if opts[:exclude_expect_blocks]
145
339
  end
146
340
 
147
- if do1 && (!opts[:title_match] || (lm && lm[2] && lm[2].match(opts[:title_match])))
341
+ in_block = true
342
+ if do1 && (!opts[:title_match] || (lm && lm[:name] && lm[:name].match(opts[:title_match])))
148
343
  current = []
149
- in_block = true
150
- block_title = (lm && lm[2])
344
+ block_title = (lm && lm[:name])
151
345
  end
152
-
153
346
  end
154
347
  elsif current
155
348
  current += [line.chomp]
156
349
  end
157
350
  end
158
- blocks.tap { |ret| puts "get_blocks() ret: #{ret.inspect}" if $pdebug }
159
- end # get_blocks
160
-
161
- def make_block_label(block, call_options = {})
162
- opts = options.merge(call_options)
163
- puts "make_block_label() opts: #{opts.inspect}" if $pdebug
164
- puts "make_block_label() block: #{block.inspect}" if $pdebug
165
- if opts[:mdheadings]
166
- heads = block.fetch(:headings, []).compact.join(' # ')
167
- "#{block[:title]} [#{heads}] (#{opts[:mdfilename]})"
168
- else
169
- "#{block[:title]} (#{opts[:mdfilename]})"
170
- end
351
+ blocks.tap_inspect
171
352
  end
172
353
 
173
- def make_block_labels(call_options = {})
174
- opts = options.merge(call_options)
175
- get_blocks(opts).map do |block|
176
- make_block_label block, opts
177
- end
354
+ def list_files_per_options(options)
355
+ default_filename = 'README.md'
356
+ default_folder = '.'
357
+ if options[:filename]&.present?
358
+ list_files_specified(options[:filename], options[:path], default_filename, default_folder)
359
+ else
360
+ list_files_specified(nil, options[:path], default_filename, default_folder)
361
+ end.tap_inspect
178
362
  end
179
363
 
180
- def select_block(call_options = {}, &options_block)
181
- opts = copts call_options, options_block
182
-
183
- blocks = get_blocks(opts.merge(struct: true))
184
- puts "select_block() blocks: #{blocks.to_yaml}" if $pdebug
185
-
186
- prompt = TTY::Prompt.new(interrupt: :exit)
187
- pt = "#{opts.fetch(:prompt, nil) || 'Pick one'}:"
188
- puts "select_block() pt: #{pt.inspect}" if $pdebug
189
-
190
- blocks.each { |block| block.merge! label: make_block_label(block, opts) }
191
- block_labels = blocks.map { |block| block[:label] }
192
- puts "select_block() block_labels: #{block_labels.inspect}" if $pdebug
193
-
194
- if opts[:preview_options]
195
- select_per_page = 3
196
- block_labels.each do |bn|
197
- fout " - #{bn}"
198
- end
364
+ def list_files_specified(specified_filename, specified_folder, default_filename, default_folder, filetree = nil)
365
+ fn = File.join(if specified_filename&.present?
366
+ if specified_folder&.present?
367
+ [specified_folder, specified_filename]
368
+ elsif specified_filename.start_with? '/'
369
+ [specified_filename]
370
+ else
371
+ [default_folder, specified_filename]
372
+ end
373
+ elsif specified_folder&.present?
374
+ if filetree
375
+ [specified_folder, @options[:md_filename_match]]
376
+ else
377
+ [specified_folder, @options[:md_filename_glob]]
378
+ end
379
+ else
380
+ [default_folder, default_filename]
381
+ end)
382
+ if filetree
383
+ filetree.select { |filename| filename == fn || filename.match(/^#{fn}$/) || filename.match(%r{^#{fn}/.+$}) }
199
384
  else
200
- select_per_page = SELECT_PAGE_HEIGHT
201
- end
202
-
203
- return nil if block_labels.count.zero?
385
+ Dir.glob(fn)
386
+ end.tap_inspect
387
+ end
204
388
 
205
- sel = prompt.select(pt, block_labels, per_page: select_per_page)
206
- puts "select_block() sel: #{sel.inspect}" if $pdebug
207
- # catch
208
- # # catch TTY::Reader::InputInterrupt
209
- # puts "InputInterrupt"
210
- # end
389
+ def list_markdown_files_in_path
390
+ Dir.glob(File.join(@options[:path], @options[:md_filename_glob])).tap_inspect
391
+ end
211
392
 
212
- label_block = blocks.select { |block| block[:label] == sel }.fetch(0, nil)
213
- puts "select_block() label_block: #{label_block.inspect}" if $pdebug
214
- sel = label_block[:name]
215
- puts "select_block() sel: #{sel.inspect}" if $pdebug
393
+ def list_named_blocks_in_file(call_options = {}, &options_block)
394
+ opts = optsmerge call_options, options_block
395
+ block_name_excluded_match = Regexp.new opts[:block_name_excluded_match]
396
+ list_blocks_in_file(opts).map do |block|
397
+ next if opts[:exclude_matching_block_names] && block[:name].match(block_name_excluded_match)
216
398
 
217
- cbs = code_blocks(blocks, sel)
218
- puts "select_block() cbs: #{cbs.inspect}" if $pdebug
399
+ block
400
+ end.compact.tap_inspect
401
+ end
219
402
 
220
- ## display code blocks for approval
221
- #
222
- cbs.each { |cb| fout cb } if opts[:display] || opts[:approve]
403
+ def list_recursively_required_blocks(table, name)
404
+ name_block = get_block_by_name(table, name)
405
+ raise "Named code block `#{name}` not found." if name_block.nil? || name_block.keys.empty?
223
406
 
224
- allow = true
225
- allow = prompt.yes? 'Process?' if opts[:approve]
226
-
227
- selected = block_by_name blocks, sel
228
- puts "select_block() selected: #{selected.inspect}" if $pdebug
229
- if allow && opts[:execute]
230
-
231
- ## process in script, to handle line continuations
232
- #
233
- cmd2 = cbs.flatten.join("\n")
234
- fout "$ #{cmd2.to_yaml}"
235
-
236
- # Open3.popen3(cmd2) do |stdin, stdout, stderr, wait_thr|
237
- # cnt += 1
238
- # # stdin.puts "This is sent to the command"
239
- # # stdin.close # we're done
240
- # stdout_str = stdout.read # read stdout to string. note that this will block until the command is done!
241
- # stderr_str = stderr.read # read stderr to string
242
- # status = wait_thr.value # will block until the command finishes; returns status that responds to .success?
243
- # fout "#{stdout_str}"
244
- # fout "#{cnt}: err: #{stderr_str}" if stderr_str != ''
245
- # # fout "#{cnt}: stt: #{status}"
246
- # end
247
-
248
- Open3.popen3(cmd2) do |stdin, stdout, stderr|
249
- stdin.close_write
250
- begin
251
- files = [stdout, stderr]
252
-
253
- until all_eof(files)
254
- ready = IO.select(files)
255
-
256
- next unless ready
257
-
258
- readable = ready[0]
259
- # writable = ready[1]
260
- # exceptions = ready[2]
407
+ all = [name_block[:name]] + recursively_required(table, name_block[:reqs])
261
408
 
262
- readable.each do |f|
263
- # fileno = f.fileno
409
+ # in order of appearance in document
410
+ table.select { |block| all.include? block[:name] }
411
+ .map { |block| block.fetch(:body, '') }
412
+ .flatten(1)
413
+ .tap_inspect
414
+ end
264
415
 
265
- data = f.read_nonblock(BLOCK_SIZE)
266
- # fout "- fileno: #{fileno}\n#{data}"
267
- fout data
268
- rescue EOFError #=> e
269
- # fout "fileno: #{fileno} EOF"
270
- end
271
- end
272
- rescue IOError => e
273
- fout "IOError: #{e}"
274
- end
275
- end
416
+ def make_block_label(block, call_options = {})
417
+ opts = options.merge(call_options)
418
+ if opts[:mdheadings]
419
+ heads = block.fetch(:headings, []).compact.join(' # ')
420
+ "#{block[:title]} [#{heads}] (#{opts[:filename]})"
421
+ else
422
+ "#{block[:title]} (#{opts[:filename]})"
276
423
  end
424
+ end
277
425
 
278
- selected[:name]
279
- end # select_block
280
-
281
- def select_md_file
282
- opts = options
283
- files = find_files
284
- if files.count == 1
285
- sel = files[0]
286
- elsif files.count >= 2
287
-
288
- if opts[:preview_options]
289
- select_per_page = 3
290
- files.each do |file|
291
- fout " - #{file}"
292
- end
293
- else
294
- select_per_page = SELECT_PAGE_HEIGHT
295
- end
296
-
297
- prompt = TTY::Prompt.new
298
- sel = prompt.select("#{opts.fetch(:prompt, 'Pick one')}:", files, per_page: select_per_page)
299
- end
426
+ def make_block_labels(call_options = {})
427
+ opts = options.merge(call_options)
428
+ list_blocks_in_file(opts).map do |block|
429
+ # next if opts[:exclude_matching_block_names] && block[:name].match(%r{^:\(.+\)$})
300
430
 
301
- sel
431
+ make_block_label block, opts
432
+ end.compact.tap_inspect
302
433
  end
303
434
 
304
- # Returns true if all files are EOF
305
- #
306
- def all_eof(files)
307
- files.find { |f| !f.eof }.nil?
435
+ def option_exclude_blocks(opts, blocks)
436
+ block_name_excluded_match = Regexp.new opts[:block_name_excluded_match]
437
+ if opts[:exclude_matching_block_names]
438
+ blocks.reject { |block| block[:name].match(block_name_excluded_match) }
439
+ else
440
+ blocks
441
+ end
308
442
  end
309
443
 
310
- def code(table, block)
311
- puts "code() table: #{table.inspect}" if $pdebug
312
- puts "code() block: #{block.inspect}" if $pdebug
313
- all = [block[:name]] + unroll(table, block[:reqs])
314
- puts "code() all: #{all.inspect}" if $pdebug
315
- all.reverse.map do |req|
316
- puts "code() req: #{req.inspect}" if $pdebug
317
- block_by_name(table, req).fetch(:body, '')
318
- end
319
- .flatten(1)
320
- .tap { |ret| puts "code() ret: #{ret.inspect}" if $pdebug }
444
+ def optsmerge(call_options = {}, options_block = nil)
445
+ class_call_options = @options.merge(call_options || {})
446
+ if options_block
447
+ options_block.call class_call_options
448
+ else
449
+ class_call_options
450
+ end.tap_inspect
321
451
  end
322
452
 
323
- def block_by_name(table, name, default = {})
324
- table.select { |block| block[:name] == name }.fetch(0, default)
453
+ def output_execution_summary
454
+ fout_section 'summary', {
455
+ execute_aborted_at: @execute_aborted_at,
456
+ execute_completed_at: @execute_completed_at,
457
+ execute_error: @execute_error,
458
+ execute_error_message: @execute_error_message,
459
+ execute_files: @execute_files,
460
+ execute_options: @execute_options,
461
+ execute_started_at: @execute_started_at,
462
+ execute_script_filespec: @execute_script_filespec
463
+ }
325
464
  end
326
465
 
327
- def code_blocks(table, name)
328
- puts "code_blocks() table: #{table.inspect}" if $pdebug
329
- puts "code_blocks() name: #{name.inspect}" if $pdebug
330
- name_block = block_by_name(table, name)
331
- puts "code_blocks() name_block: #{name_block.inspect}" if $pdebug
332
- all = [name_block[:name]] + unroll(table, name_block[:reqs])
333
- puts "code_blocks() all: #{all.inspect}" if $pdebug
466
+ def read_configuration_file!(options, configuration_path)
467
+ return unless File.exist?(configuration_path)
334
468
 
335
- # in order of appearance in document
336
- table.select { |block| all.include? block[:name] }
337
- .map { |block| block.fetch(:body, '') }
338
- .flatten(1)
339
- .tap { |ret| puts "code_blocks() ret: #{ret.inspect}" if $pdebug }
469
+ # rubocop:disable Security/YAMLLoad
470
+ options.merge!((YAML.load(File.open(configuration_path)) || {})
471
+ .transform_keys(&:to_sym))
472
+ # rubocop:enable Security/YAMLLoad
340
473
  end
341
474
 
342
- def unroll(table, reqs)
343
- puts "unroll() table: #{table.inspect}" if $pdebug
344
- puts "unroll() reqs: #{reqs.inspect}" if $pdebug
475
+ def recursively_required(table, reqs)
345
476
  all = []
346
477
  rem = reqs
347
478
  while rem.count.positive?
348
- puts "unrol() rem: #{rem.inspect}" if $pdebug
349
479
  rem = rem.map do |req|
350
- puts "unrol() req: #{req.inspect}" if $pdebug
351
480
  next if all.include? req
352
481
 
353
482
  all += [req]
354
- puts "unrol() all: #{all.inspect}" if $pdebug
355
- block_by_name(table, req).fetch(:reqs, [])
483
+ get_block_by_name(table, req).fetch(:reqs, [])
356
484
  end
357
485
  .compact
358
486
  .flatten(1)
359
- .tap { |_ret| puts "unroll() rem: #{rem.inspect}" if $pdebug }
487
+ .tap_inspect(name: 'rem')
360
488
  end
361
- all.tap { |ret| puts "unroll() ret: #{ret.inspect}" if $pdebug }
362
- end
363
-
364
- # $stderr.sync = true
365
- # $stdout.sync = true
366
-
367
- ## configuration file
368
- #
369
- def read_configuration!(options, configuration_path)
370
- if File.exist?(configuration_path)
371
- # rubocop:disable Security/YAMLLoad
372
- options.merge!((YAML.load(File.open(configuration_path)) || {})
373
- .transform_keys(&:to_sym))
374
- # rubocop:enable Security/YAMLLoad
375
- end
376
- options
489
+ all.tap_inspect
377
490
  end
378
491
 
379
492
  def run
380
493
  ## default configuration
381
494
  #
382
- options = {
383
- mdheadings: true,
384
- list_blocks: false,
385
- list_docs: false,
386
- mdfilename: 'README.md',
387
- mdfolder: '.'
388
- }
495
+ @options = base_options
389
496
 
390
- def options_finalize!(options); end
497
+ ## post-parse options configuration
498
+ #
499
+ options_finalize = ->(_options) {}
391
500
 
392
- # puts "MDE run() ARGV: #{ARGV.inspect}"
501
+ proc_self = ->(value) { value }
502
+ proc_to_i = ->(value) { value.to_i != 0 }
503
+ proc_true = ->(_) { true }
393
504
 
394
505
  # read local configuration file
395
506
  #
396
- read_configuration! options, ".#{MarkdownExec::APP_NAME.downcase}.yml"
507
+ read_configuration_file! @options, ".#{MarkdownExec::APP_NAME.downcase}.yml"
397
508
 
398
- ## read current details for aws resources from app_data_file
399
- #
400
- # load_resources! options
401
- # puts "q31 options: #{options.to_yaml}" if $pdebug
402
-
403
- # rubocop:disable Metrics/BlockLength
404
509
  option_parser = OptionParser.new do |opts|
405
510
  executable_name = File.basename($PROGRAM_NAME)
406
511
  opts.banner = [
407
- "#{MarkdownExec::APP_NAME} - #{MarkdownExec::APP_DESC} (#{MarkdownExec::VERSION})",
408
- "Usage: #{executable_name} [options]"
512
+ "#{MarkdownExec::APP_NAME}" \
513
+ " - #{MarkdownExec::APP_DESC} (#{MarkdownExec::VERSION})",
514
+ "Usage: #{executable_name} [path] [filename] [options]"
409
515
  ].join("\n")
410
516
 
411
- ## menu top: on_head appear in reverse order added
412
- #
413
- opts.on('--config PATH', 'Read configuration file') do |value|
414
- read_configuration! options, value
415
- end
416
-
417
- ## menu body: items appear in order added
418
- #
419
- opts.on('-f RELATIVE', '--mdfilename', 'Name of document') do |value|
420
- options[:mdfilename] = value
421
- end
422
-
423
- opts.on('-p PATH', '--mdfolder', 'Path to documents') do |value|
424
- options[:mdfolder] = value
425
- end
426
-
427
- opts.on('--list-blocks', 'List blocks') do |_value|
428
- options[:list_blocks] = true
429
- end
430
-
431
- opts.on('--list-docs', 'List docs in current folder') do |_value|
432
- options[:list_docs] = true
517
+ summary_head = [
518
+ ['config', nil, nil, 'PATH', 'Read configuration file',
519
+ nil, ->(value) { read_configuration_file! options, value }],
520
+ ['debug', 'd', 'MDE_DEBUG', 'BOOL', 'Debug output',
521
+ nil, ->(value) { $pdebug = value.to_i != 0 }]
522
+ ]
523
+
524
+ summary_body = [
525
+ ['filename', 'f', 'MDE_FILENAME', 'RELATIVE', 'Name of document',
526
+ :filename, proc_self],
527
+ ['list-blocks', nil, nil, nil, 'List blocks',
528
+ :list_blocks, proc_true],
529
+ ['list-docs', nil, nil, nil, 'List docs in current folder',
530
+ :list_docs, proc_true],
531
+ ['output-execution-summary', nil, 'MDE_OUTPUT_EXECUTION_SUMMARY', 'BOOL', 'Display summary for execution',
532
+ :output_execution_summary, proc_to_i],
533
+ ['output-script', nil, 'MDE_OUTPUT_SCRIPT', 'BOOL', 'Display script',
534
+ :output_script, proc_to_i],
535
+ ['output-stdout', nil, 'MDE_OUTPUT_STDOUT', 'BOOL', 'Display standard output from execution',
536
+ :output_stdout, proc_to_i],
537
+ ['path', 'p', 'MDE_PATH', 'PATH', 'Path to documents',
538
+ :path, proc_self],
539
+ ['save-executed-script', nil, 'MDE_SAVE_EXECUTED_SCRIPT', 'BOOL', 'Save executed script',
540
+ :save_executed_script, proc_to_i],
541
+ ['saved-script-folder', nil, 'MDE_SAVED_SCRIPT_FOLDER', 'SPEC', 'Saved script folder',
542
+ :saved_script_folder, proc_self],
543
+ ['user-must-approve', nil, 'MDE_USER_MUST_APPROVE', 'BOOL', 'Pause to approve execution',
544
+ :user_must_approve, proc_to_i]
545
+ ]
546
+
547
+ # rubocop:disable Style/Semicolon
548
+ summary_tail = [
549
+ [nil, '0', nil, nil, 'Show configuration',
550
+ nil, ->(_) { options_finalize.call options; fout options.to_yaml }],
551
+ ['help', 'h', nil, nil, 'App help',
552
+ nil, ->(_) { fout option_parser.help; exit }],
553
+ ['version', 'v', nil, nil, 'App version',
554
+ nil, ->(_) { fout MarkdownExec::VERSION; exit }],
555
+ ['exit', 'x', nil, nil, 'Exit app',
556
+ nil, ->(_) { exit }]
557
+ ]
558
+ # rubocop:enable Style/Semicolon
559
+
560
+ (summary_head + summary_body + summary_tail)
561
+ .map do |long_name, short_name, env_var, arg_name, description, opt_name, proc1| # rubocop:disable Metrics/ParameterLists
562
+ opts.on(*[long_name.present? ? "--#{long_name}#{arg_name.present? ? (' ' + arg_name) : ''}" : nil,
563
+ short_name.present? ? "-#{short_name}" : nil,
564
+ [description,
565
+ env_var.present? ? "env: #{env_var}" : nil].compact.join(' - '),
566
+ lambda { |value|
567
+ ret = proc1.call(value)
568
+ options[opt_name] = ret if opt_name
569
+ ret
570
+ }].compact)
433
571
  end
572
+ end
573
+ option_parser.load # filename defaults to basename of the program without suffix in a directory ~/.options
574
+ option_parser.environment # env defaults to the basename of the program.
575
+ rest = option_parser.parse! # (into: options)
434
576
 
435
- ## menu bottom: items appear in order added
436
- #
437
- opts.on_tail('-h', '--help', 'App help') do |_value|
438
- puts option_parser.help
439
- exit
440
- end
577
+ ## finalize configuration
578
+ #
579
+ options_finalize.call options
441
580
 
442
- opts.on_tail('-v', '--version', 'App version') do |_value|
443
- puts MarkdownExec::VERSION
444
- exit
581
+ ## position 0: file or folder (optional)
582
+ #
583
+ if (pos = rest.fetch(0, nil))&.present?
584
+ if Dir.exist?(pos)
585
+ options[:path] = pos
586
+ elsif File.exist?(pos)
587
+ options[:filename] = pos
588
+ else
589
+ raise "Invalid parameter: #{pos}"
445
590
  end
591
+ end
446
592
 
447
- opts.on_tail('-x', '--exit', 'Exit app') do |_value|
448
- exit
449
- end
593
+ ## position 1: block name (optional)
594
+ #
595
+ block_name = rest.fetch(1, nil)
450
596
 
451
- opts.on_tail('-0', 'Show configuration') do |_v|
452
- options_finalize! options
453
- puts options.to_yaml
454
- end
455
- end # OptionParser
456
- # rubocop:enable Metrics/BlockLength
597
+ exec_block options, block_name
598
+ end
457
599
 
458
- option_parser.load # filename defaults to basename of the program without suffix in a directory ~/.options
459
- option_parser.environment # env defaults to the basename of the program.
460
- option_parser.parse! # (into: options)
461
- options_finalize! options
600
+ def select_and_approve_block(call_options = {}, &options_block)
601
+ opts = optsmerge call_options, options_block
602
+ blocks_in_file = list_blocks_in_file(opts.merge(struct: true))
462
603
 
463
- ## process
464
- #
465
- # rubocop:disable Metrics/BlockLength
466
- loop do # once
467
- mp = MarkParse.new options
468
- options.merge!(
469
- {
470
- approve: true,
471
- bash: true,
472
- display: true,
473
- exclude_expect_blocks: true,
474
- execute: true,
475
- prompt: 'Execute',
476
- struct: true
477
- }
478
- )
479
-
480
- ## show
481
- #
482
- if options[:list_docs]
483
- fout mp.find_files
484
- break
485
- end
604
+ unless opts[:block_name].present?
605
+ pt = (opts[:prompt_select_block]).to_s
606
+ blocks_in_file.each { |block| block.merge! label: make_block_label(block, opts) }
607
+ block_labels = option_exclude_blocks(opts, blocks_in_file).map { |block| block[:label] }
486
608
 
487
- if options[:list_blocks]
488
- fout (mp.find_files.map do |file|
489
- mp.make_block_labels(mdfilename: file, struct: true)
490
- end).flatten(1).to_yaml
491
- break
492
- end
609
+ return nil if block_labels.count.zero?
493
610
 
494
- ## process
495
- #
496
- mp.select_block(bash: true, struct: true) if options[:mdfilename]
611
+ sel = @prompt.select(pt, block_labels, per_page: opts[:select_page_height])
612
+ label_block = blocks_in_file.select { |block| block[:label] == sel }.fetch(0, nil)
613
+ opts[:block_name] = label_block[:name]
614
+ end
497
615
 
498
- # rubocop:disable Style/BlockComments
499
- =begin
500
- # rescue ArgumentError => e
501
- # puts "User abort: #{e}"
616
+ approve_block opts, blocks_in_file
617
+ end
502
618
 
503
- # rescue StandardError => e
504
- # puts "ERROR: #{e}"
505
- # raise StandardError, e
619
+ def select_md_file(files_ = nil)
620
+ opts = options
621
+ files = files_ || list_markdown_files_in_path
622
+ if files.count == 1
623
+ files[0]
624
+ elsif files.count >= 2
625
+ @prompt.select(opts[:prompt_select_md].to_s, files, per_page: opts[:select_page_height])
626
+ end
627
+ end
506
628
 
507
- # ensure
508
- # exit
509
- =end
510
- # rubocop:enable Style/BlockComments
629
+ def summarize_block(headings, title)
630
+ { headings: headings, name: title, title: title }
631
+ end
511
632
 
512
- break unless false # rubocop:disable Lint/LiteralAsCondition
513
- end # loop
514
- end # run
515
- end # class MarkParse
633
+ def update_options(opts = {}, over: true)
634
+ if over
635
+ @options = @options.merge opts
636
+ else
637
+ @options.merge! opts
638
+ end
639
+ @options
640
+ end
516
641
 
517
- # rubocop:enable Metrics/BlockLength
518
- # rubocop:enable Style/GlobalVars
519
- end # module MarkdownExec
642
+ def write_command_file(opts, required_blocks)
643
+ return unless opts[:saved_script_filename].present?
644
+
645
+ fne = File.basename(opts[:filename], '.*').gsub(/[^a-z0-9]/i, '-') # scan(/[a-z0-9]/i).join
646
+ bne = opts[:block_name].gsub(/[^a-z0-9]/i, '-') # scan(/[a-z0-9]/i).join
647
+ opts[:saved_script_filename] = "mde_#{Time.now.utc.strftime '%F-%H-%M-%S'}_#{fne}_#{bne}.sh"
648
+
649
+ @options[:saved_filespec] = File.join opts[:saved_script_folder], opts[:saved_script_filename]
650
+ @execute_script_filespec = @options[:saved_filespec]
651
+ dirname = File.dirname(@options[:saved_filespec])
652
+ Dir.mkdir dirname unless File.exist?(dirname)
653
+ File.write(@options[:saved_filespec], "#!/usr/bin/env bash\n" \
654
+ "# file_name: #{opts[:filename]}\n" \
655
+ "# block_name: #{opts[:block_name]}\n" \
656
+ "# time: #{Time.now.utc}\n" \
657
+ "#{required_blocks.flatten.join("\n")}\n")
658
+ end
659
+ end
660
+ end