markdown_exec 0.2.1 → 0.2.5

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,24 +3,53 @@
3
3
 
4
4
  # encoding=utf-8
5
5
 
6
- $pdebug = !(ENV['MARKDOWN_EXEC_DEBUG'] || '').empty?
7
-
8
6
  require 'open3'
9
7
  require 'optparse'
10
8
  require 'tty-prompt'
11
9
  require 'yaml'
12
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 name.nil? || (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 name.nil? || (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
+ return default if name.nil? || (val = ENV[name]).nil?
32
+
33
+ val || default
34
+ end
35
+
36
+ $pdebug = env_bool 'MDE_DEBUG'
37
+
13
38
  require_relative 'markdown_exec/version'
14
39
 
15
40
  $stderr.sync = true
16
41
  $stdout.sync = true
17
42
 
18
43
  BLOCK_SIZE = 1024
19
- SELECT_PAGE_HEIGHT = 12
20
44
 
21
45
  class Object # rubocop:disable Style/Documentation
22
46
  def present?
23
- self && !blank?
47
+ case self.class.to_s
48
+ when 'FalseClass', 'TrueClass'
49
+ true
50
+ else
51
+ self && (!respond_to?(:blank?) || !blank?)
52
+ end
24
53
  end
25
54
  end
26
55
 
@@ -31,6 +60,30 @@ class String # rubocop:disable Style/Documentation
31
60
  end
32
61
  end
33
62
 
63
+ public
64
+
65
+ # debug output
66
+ #
67
+ def tap_inspect(format: nil, name: 'return')
68
+ return self unless $pdebug
69
+
70
+ fn = case format
71
+ when :json
72
+ :to_json
73
+ when :string
74
+ :to_s
75
+ when :yaml
76
+ :to_yaml
77
+ else
78
+ :inspect
79
+ end
80
+
81
+ puts "-> #{caller[0].scan(/in `?(\S+)'$/)[0][0]}()" \
82
+ " #{name}: #{method(fn).call}"
83
+
84
+ self
85
+ end
86
+
34
87
  module MarkdownExec
35
88
  class Error < StandardError; end
36
89
 
@@ -41,6 +94,38 @@ module MarkdownExec
41
94
 
42
95
  def initialize(options = {})
43
96
  @options = options
97
+ @prompt = TTY::Prompt.new(interrupt: :exit)
98
+ end
99
+
100
+ ##
101
+ # options necessary to start, parse input, defaults for cli options
102
+
103
+ def base_options
104
+ menu_data
105
+ .map do |_long_name, _short_name, env_var, _arg_name, _description, opt_name, default, _proc1| # rubocop:disable Metrics/ParameterLists
106
+ next unless opt_name.present?
107
+
108
+ [opt_name, env_bool(env_var, default: value_for_hash(default))]
109
+ end.compact.to_h.merge(
110
+ {
111
+ mdheadings: true, # use headings (levels 1,2,3) in block lable
112
+ menu_with_exit: true
113
+ }
114
+ ).tap_inspect format: :yaml
115
+ end
116
+
117
+ def default_options
118
+ {
119
+ bash: true, # bash block parsing in get_block_summary()
120
+ exclude_expect_blocks: true,
121
+ hide_blocks_by_name: true,
122
+ output_saved_script_filename: false,
123
+ prompt_approve_block: 'Process?',
124
+ prompt_select_block: 'Choose a block:',
125
+ prompt_select_md: 'Choose a file:',
126
+ saved_script_filename: nil, # calculated
127
+ struct: true # allow get_block_summary()
128
+ }
44
129
  end
45
130
 
46
131
  # Returns true if all files are EOF
@@ -49,16 +134,155 @@ module MarkdownExec
49
134
  files.find { |f| !f.eof }.nil?
50
135
  end
51
136
 
137
+ def approve_block(opts, blocks_in_file)
138
+ required_blocks = list_recursively_required_blocks(blocks_in_file, opts[:block_name])
139
+ display_command(opts, required_blocks) if opts[:output_script] || opts[:user_must_approve]
140
+
141
+ allow = true
142
+ allow = @prompt.yes? opts[:prompt_approve_block] if opts[:user_must_approve]
143
+ opts[:ir_approve] = allow
144
+ selected = get_block_by_name blocks_in_file, opts[:block_name]
145
+
146
+ if opts[:ir_approve]
147
+ write_command_file(opts, required_blocks) if opts[:save_executed_script]
148
+ command_execute opts, required_blocks.flatten.join("\n")
149
+ end
150
+
151
+ selected[:name]
152
+ end
153
+
154
+ def code(table, block)
155
+ all = [block[:name]] + recursively_required(table, block[:reqs])
156
+ all.reverse.map do |req|
157
+ get_block_by_name(table, req).fetch(:body, '')
158
+ end
159
+ .flatten(1)
160
+ .tap_inspect
161
+ end
162
+
163
+ def command_execute(opts, cmd2)
164
+ @execute_files = Hash.new([])
165
+ @execute_options = opts
166
+ @execute_started_at = Time.now.utc
167
+ Open3.popen3(cmd2) do |stdin, stdout, stderr|
168
+ stdin.close_write
169
+ begin
170
+ files = [stdout, stderr]
171
+
172
+ until all_at_eof(files)
173
+ ready = IO.select(files)
174
+
175
+ next unless ready
176
+
177
+ # readable = ready[0]
178
+ # # writable = ready[1]
179
+ # # exceptions = ready[2]
180
+ ready.each.with_index do |readable, ind|
181
+ readable.each do |f|
182
+ block = f.read_nonblock(BLOCK_SIZE)
183
+ @execute_files[ind] = @execute_files[ind] + [block]
184
+ print block if opts[:output_stdout]
185
+ rescue EOFError #=> e
186
+ # do nothing at EOF
187
+ end
188
+ end
189
+ end
190
+ rescue IOError => e
191
+ fout "IOError: #{e}"
192
+ end
193
+ @execute_completed_at = Time.now.utc
194
+ end
195
+ rescue Errno::ENOENT => e
196
+ # error triggered by missing command in script
197
+ @execute_aborted_at = Time.now.utc
198
+ @execute_error_message = e.message
199
+ @execute_error = e
200
+ @execute_files[1] = e.message
201
+ fout "Error ENOENT: #{e.inspect}"
202
+ end
203
+
52
204
  def count_blocks_in_filename
205
+ fenced_start_and_end_match = Regexp.new @options[:fenced_start_and_end_match]
53
206
  cnt = 0
54
- File.readlines(options[:filename]).each do |line|
55
- cnt += 1 if line.match(/^```/)
207
+ File.readlines(@options[:filename]).each do |line|
208
+ cnt += 1 if line.match(fenced_start_and_end_match)
56
209
  end
57
210
  cnt / 2
58
211
  end
59
212
 
213
+ def display_command(_opts, required_blocks)
214
+ required_blocks.each { |cb| fout cb }
215
+ end
216
+
217
+ def exec_block(options, _block_name = '')
218
+ options = default_options.merge options
219
+ update_options options, over: false
220
+
221
+ # document and block reports
222
+ #
223
+ files = list_files_per_options(options)
224
+ if @options[:list_blocks]
225
+ fout_list (files.map do |file|
226
+ make_block_labels(filename: file, struct: true)
227
+ end).flatten(1)
228
+ return
229
+ end
230
+
231
+ if @options[:list_default_yaml]
232
+ fout_list list_default_yaml
233
+ return
234
+ end
235
+
236
+ if @options[:list_docs]
237
+ fout_list files
238
+ return
239
+ end
240
+
241
+ if @options[:list_default_env]
242
+ fout_list list_default_env
243
+ return
244
+ end
245
+
246
+ if @options[:list_recent_scripts]
247
+ fout_list list_recent_scripts
248
+ return
249
+ end
250
+
251
+ if @options[:run_last_script]
252
+ run_last_script
253
+ return
254
+ end
255
+
256
+ if @options[:select_recent_script]
257
+ select_recent_script
258
+ return
259
+ end
260
+
261
+ # process
262
+ #
263
+ @options[:filename] = select_md_file(files)
264
+ select_and_approve_block(
265
+ bash: true,
266
+ struct: true
267
+ )
268
+ fout "saved_filespec: #{@execute_script_filespec}" if @options[:output_saved_script_filename]
269
+ save_execution_output
270
+ output_execution_summary
271
+ end
272
+
273
+ # standard output; not for debug
274
+ #
60
275
  def fout(str)
61
- puts str # to stdout
276
+ puts str
277
+ end
278
+
279
+ def fout_list(str)
280
+ puts str
281
+ end
282
+
283
+ def fout_section(name, data)
284
+ puts "# #{name}"
285
+ puts data.to_yaml
62
286
  end
63
287
 
64
288
  def get_block_by_name(table, name, default = {})
@@ -70,11 +294,11 @@ module MarkdownExec
70
294
 
71
295
  return [summarize_block(headings, block_title).merge({ body: current })] unless opts[:bash]
72
296
 
73
- bm = block_title.match(/:(\S+)( |$)/)
74
- reqs = block_title.scan(/\+\S+/).map { |s| s[1..] }
297
+ bm = block_title.match(Regexp.new(opts[:block_name_match]))
298
+ reqs = block_title.scan(Regexp.new(opts[:block_required_scan])).map { |s| s[1..] }
75
299
 
76
300
  if bm && bm[1]
77
- [summarize_block(headings, bm[1]).merge({ body: current, reqs: reqs })]
301
+ [summarize_block(headings, bm[:title]).merge({ body: current, reqs: reqs })]
78
302
  else
79
303
  [summarize_block(headings, block_title).merge({ body: current, reqs: reqs })]
80
304
  end
@@ -88,136 +312,148 @@ module MarkdownExec
88
312
  exit 1
89
313
  end
90
314
 
315
+ fenced_start_and_end_match = Regexp.new opts[:fenced_start_and_end_match]
316
+ fenced_start_ex = Regexp.new opts[:fenced_start_ex_match]
317
+ block_title = ''
91
318
  blocks = []
92
319
  current = nil
93
- in_block = false
94
- block_title = ''
95
-
96
320
  headings = []
321
+ in_block = false
97
322
  File.readlines(opts[:filename]).each do |line|
98
323
  continue unless line
99
324
 
100
325
  if opts[:mdheadings]
101
- if (lm = line.match(/^### *(.+?) *$/))
102
- headings = [headings[0], headings[1], lm[1]]
103
- elsif (lm = line.match(/^## *([^#]*?) *$/))
104
- headings = [headings[0], lm[1]]
105
- elsif (lm = line.match(/^# *([^#]*?) *$/))
106
- headings = [lm[1]]
326
+ if (lm = line.match(Regexp.new(opts[:heading3_match])))
327
+ headings = [headings[0], headings[1], lm[:name]]
328
+ elsif (lm = line.match(Regexp.new(opts[:heading2_match])))
329
+ headings = [headings[0], lm[:name]]
330
+ elsif (lm = line.match(Regexp.new(opts[:heading1_match])))
331
+ headings = [lm[:name]]
107
332
  end
108
333
  end
109
334
 
110
- if line.match(/^`{3,}/)
335
+ if line.match(fenced_start_and_end_match)
111
336
  if in_block
112
337
  if current
113
-
114
338
  block_title = current.join(' ').gsub(/ +/, ' ')[0..64] if block_title.nil? || block_title.empty?
115
-
116
339
  blocks += get_block_summary opts, headings, block_title, current
117
340
  current = nil
118
341
  end
119
342
  in_block = false
120
343
  block_title = ''
121
344
  else
122
- ## new block
345
+ # new block
123
346
  #
124
-
125
- lm = line.match(/^`{3,}([^`\s]*) *(.*)$/)
347
+ lm = line.match(fenced_start_ex)
126
348
  do1 = false
127
349
  if opts[:bash_only]
128
- do1 = true if lm && (lm[1] == 'bash')
350
+ do1 = true if lm && (lm[:shell] == 'bash')
129
351
  else
130
352
  do1 = true
131
- do1 = !(lm && (lm[1] == 'expect')) if opts[:exclude_expect_blocks]
132
-
133
- # if do1 && opts[:exclude_matching_block_names]
134
- # puts " MW a4"
135
- # puts " MW a4 #{(lm[2].match %r{^:\(.+\)$})}"
136
- # do1 = !(lm && (lm[2].match %r{^:\(.+\)$}))
137
- # end
353
+ do1 = !(lm && (lm[:shell] == 'expect')) if opts[:exclude_expect_blocks]
138
354
  end
139
355
 
140
356
  in_block = true
141
- if do1 && (!opts[:title_match] || (lm && lm[2] && lm[2].match(opts[:title_match])))
357
+ if do1 && (!opts[:title_match] || (lm && lm[:name] && lm[:name].match(opts[:title_match])))
142
358
  current = []
143
- block_title = (lm && lm[2])
359
+ block_title = (lm && lm[:name])
144
360
  end
145
361
  end
146
362
  elsif current
147
363
  current += [line.chomp]
148
364
  end
149
365
  end
150
- blocks.tap { |ret| puts "list_blocks_in_file() ret: #{ret.inspect}" if $pdebug }
151
- # blocks.map do |block|
152
- # next if opts[:exclude_matching_block_names] && block[:name].match(%r{^\(.+\)$})
153
- # block
154
- # end.compact.tap { |ret| puts "list_blocks_in_file() ret: #{ret.inspect}" if $pdebug }
366
+ blocks.tap_inspect
367
+ end
368
+
369
+ def list_default_env
370
+ menu_data
371
+ .map do |_long_name, _short_name, env_var, _arg_name, description, _opt_name, default, _proc1| # rubocop:disable Metrics/ParameterLists
372
+ next unless env_var.present?
373
+
374
+ [
375
+ "#{env_var}=#{value_for_cli default}",
376
+ description.present? ? description : nil
377
+ ].compact.join(' # ')
378
+ end.compact.sort
379
+ end
380
+
381
+ def list_default_yaml
382
+ menu_data
383
+ .map do |_long_name, _short_name, _env_var, _arg_name, description, opt_name, default, _proc1| # rubocop:disable Metrics/ParameterLists
384
+ next unless opt_name.present? && default.present?
385
+
386
+ [
387
+ "#{opt_name}: #{value_for_yaml default}",
388
+ description.present? ? description : nil
389
+ ].compact.join(' # ')
390
+ end.compact.sort
155
391
  end
156
392
 
157
393
  def list_files_per_options(options)
158
- default_filename = 'README.md'
159
- default_folder = '.'
160
- if options[:filename]&.present?
161
- list_files_specified(options[:filename], options[:folder], default_filename, default_folder)
162
- else
163
- list_files_specified(nil, options[:folder], default_filename, default_folder)
164
- end
394
+ list_files_specified(
395
+ options[:filename]&.present? ? options[:filename] : nil,
396
+ options[:path],
397
+ 'README.md',
398
+ '.'
399
+ ).tap_inspect
165
400
  end
166
401
 
167
402
  def list_files_specified(specified_filename, specified_folder, default_filename, default_folder, filetree = nil)
168
- fn = if specified_filename&.present?
169
- if specified_folder&.present?
170
- "#{specified_folder}/#{specified_filename}"
171
- else
172
- "#{default_folder}/#{specified_filename}"
173
- end
174
- elsif specified_folder&.present?
175
- if filetree
176
- "#{specified_folder}/.+\\.md"
177
- else
178
- "#{specified_folder}/*.[Mm][Dd]"
179
- end
180
- else
181
- "#{default_folder}/#{default_filename}"
182
- end
403
+ fn = File.join(if specified_filename&.present?
404
+ if specified_folder&.present?
405
+ [specified_folder, specified_filename]
406
+ elsif specified_filename.start_with? '/'
407
+ [specified_filename]
408
+ else
409
+ [default_folder, specified_filename]
410
+ end
411
+ elsif specified_folder&.present?
412
+ if filetree
413
+ [specified_folder, @options[:md_filename_match]]
414
+ else
415
+ [specified_folder, @options[:md_filename_glob]]
416
+ end
417
+ else
418
+ [default_folder, default_filename]
419
+ end)
183
420
  if filetree
184
421
  filetree.select { |filename| filename == fn || filename.match(/^#{fn}$/) || filename.match(%r{^#{fn}/.+$}) }
185
422
  else
186
423
  Dir.glob(fn)
187
- end.tap { |ret| puts "list_files_specified() ret: #{ret.inspect}" if $pdebug }
424
+ end.tap_inspect
188
425
  end
189
426
 
190
- def list_markdown_files_in_folder
191
- Dir.glob(File.join(options[:folder], '*.md'))
427
+ def list_markdown_files_in_path
428
+ Dir.glob(File.join(@options[:path], @options[:md_filename_glob])).tap_inspect
192
429
  end
193
430
 
194
431
  def list_named_blocks_in_file(call_options = {}, &options_block)
195
432
  opts = optsmerge call_options, options_block
433
+ block_name_excluded_match = Regexp.new opts[:block_name_excluded_match]
196
434
  list_blocks_in_file(opts).map do |block|
197
- next if opts[:exclude_matching_block_names] && block[:name].match(/^\(.+\)$/)
435
+ next if opts[:hide_blocks_by_name] && block[:name].match(block_name_excluded_match)
198
436
 
199
437
  block
200
- end.compact.tap { |ret| puts "list_named_blocks_in_file() ret: #{ret.inspect}" if $pdebug }
201
- end
202
-
203
- def code(table, block)
204
- all = [block[:name]] + recursively_required(table, block[:reqs])
205
- all.reverse.map do |req|
206
- get_block_by_name(table, req).fetch(:body, '')
207
- end
208
- .flatten(1)
209
- .tap { |ret| puts "code() ret: #{ret.inspect}" if $pdebug }
438
+ end.compact.tap_inspect
210
439
  end
211
440
 
212
441
  def list_recursively_required_blocks(table, name)
213
442
  name_block = get_block_by_name(table, name)
443
+ raise "Named code block `#{name}` not found." if name_block.nil? || name_block.keys.empty?
444
+
214
445
  all = [name_block[:name]] + recursively_required(table, name_block[:reqs])
215
446
 
216
447
  # in order of appearance in document
217
448
  table.select { |block| all.include? block[:name] }
218
449
  .map { |block| block.fetch(:body, '') }
219
450
  .flatten(1)
220
- .tap { |ret| puts "list_recursively_required_blocks() ret: #{ret.inspect}" if $pdebug }
451
+ .tap_inspect
452
+ end
453
+
454
+ def list_recent_scripts
455
+ Dir.glob(File.join(@options[:saved_script_folder],
456
+ @options[:saved_script_glob])).sort[0..(options[:list_count] - 1)].reverse.tap_inspect
221
457
  end
222
458
 
223
459
  def make_block_label(block, call_options = {})
@@ -233,37 +469,166 @@ module MarkdownExec
233
469
  def make_block_labels(call_options = {})
234
470
  opts = options.merge(call_options)
235
471
  list_blocks_in_file(opts).map do |block|
236
- # next if opts[:exclude_matching_block_names] && block[:name].match(%r{^:\(.+\)$})
472
+ # next if opts[:hide_blocks_by_name] && block[:name].match(%r{^:\(.+\)$})
237
473
 
238
474
  make_block_label block, opts
239
- end.compact.tap { |ret| puts "make_block_labels() ret: #{ret.inspect}" if $pdebug }
475
+ end.compact.tap_inspect
476
+ end
477
+
478
+ def menu_data
479
+ proc_self = ->(value) { value }
480
+ proc_to_i = ->(value) { value.to_i != 0 }
481
+ proc_true = ->(_) { true }
482
+
483
+ summary_head = [
484
+ ['config', nil, nil, 'PATH', 'Read configuration file',
485
+ nil, '.', ->(value) { read_configuration_file! options, value }],
486
+ ['debug', 'd', 'MDE_DEBUG', 'BOOL', 'Debug output',
487
+ nil, false, ->(value) { $pdebug = value.to_i != 0 }]
488
+ ]
489
+
490
+ summary_body = [
491
+ ['filename', 'f', 'MDE_FILENAME', 'RELATIVE', 'Name of document',
492
+ :filename, nil, proc_self],
493
+ ['list-blocks', nil, nil, nil, 'List blocks',
494
+ :list_blocks, nil, proc_true],
495
+ ['list-default-env', nil, nil, nil, 'List default configuration as environment variables',
496
+ :list_default_env, nil, proc_true],
497
+ ['list-default-yaml', nil, nil, nil, 'List default configuration as YAML',
498
+ :list_default_yaml, nil, proc_true],
499
+ ['list-docs', nil, nil, nil, 'List docs in current folder',
500
+ :list_docs, nil, proc_true],
501
+ ['list-recent-scripts', nil, nil, nil, 'List recent saved scripts',
502
+ :list_recent_scripts, nil, proc_true],
503
+ ['output-execution-summary', nil, 'MDE_OUTPUT_EXECUTION_SUMMARY', 'BOOL', 'Display summary for execution',
504
+ :output_execution_summary, false, proc_to_i],
505
+ ['output-script', nil, 'MDE_OUTPUT_SCRIPT', 'BOOL', 'Display script prior to execution',
506
+ :output_script, false, proc_to_i],
507
+ ['output-stdout', nil, 'MDE_OUTPUT_STDOUT', 'BOOL', 'Display standard output from execution',
508
+ :output_stdout, true, proc_to_i],
509
+ ['path', 'p', 'MDE_PATH', 'PATH', 'Path to documents',
510
+ :path, nil, proc_self],
511
+ ['run-last-script', nil, nil, nil, 'Run most recently saved script',
512
+ :run_last_script, nil, proc_true],
513
+ ['select-recent-script', nil, nil, nil, 'Select and execute a recently saved script',
514
+ :select_recent_script, nil, proc_true],
515
+ ['save-execution-output', nil, 'MDE_SAVE_EXECUTION_OUTPUT', 'BOOL', 'Save execution output',
516
+ :save_execution_output, false, proc_to_i],
517
+ ['save-executed-script', nil, 'MDE_SAVE_EXECUTED_SCRIPT', 'BOOL', 'Save executed script',
518
+ :save_executed_script, false, proc_to_i],
519
+ ['saved-script-folder', nil, 'MDE_SAVED_SCRIPT_FOLDER', 'SPEC', 'Saved script folder',
520
+ :saved_script_folder, 'logs', proc_self],
521
+ ['saved-stdout-folder', nil, 'MDE_SAVED_STDOUT_FOLDER', 'SPEC', 'Saved stdout folder',
522
+ :saved_stdout_folder, 'logs', proc_self],
523
+ ['user-must-approve', nil, 'MDE_USER_MUST_APPROVE', 'BOOL', 'Pause for user to approve script',
524
+ :user_must_approve, true, proc_to_i]
525
+ ]
526
+
527
+ # rubocop:disable Style/Semicolon
528
+ summary_tail = [
529
+ [nil, '0', nil, nil, 'Show current configuration values',
530
+ nil, nil, ->(_) { options_finalize options; fout sorted_keys(options).to_yaml }],
531
+ ['help', 'h', nil, nil, 'App help',
532
+ nil, nil, ->(_) { fout menu_help; exit }],
533
+ ['version', 'v', nil, nil, 'App version',
534
+ nil, nil, ->(_) { fout MarkdownExec::VERSION; exit }],
535
+ ['exit', 'x', nil, nil, 'Exit app',
536
+ nil, nil, ->(_) { exit }]
537
+ ]
538
+ # rubocop:enable Style/Semicolon
539
+
540
+ env_vars = [
541
+ [nil, nil, 'MDE_BLOCK_NAME_EXCLUDED_MATCH', nil, 'Pattern for blocks to hide from user-selection',
542
+ :block_name_excluded_match, '^\(.+\)$', nil],
543
+ [nil, nil, 'MDE_BLOCK_NAME_MATCH', nil, '', :block_name_match, ':(?<title>\S+)( |$)', nil],
544
+ [nil, nil, 'MDE_BLOCK_REQUIRED_SCAN', nil, '', :block_required_scan, '\+\S+', nil],
545
+ [nil, nil, 'MDE_FENCED_START_AND_END_MATCH', nil, '', :fenced_start_and_end_match, '^`{3,}', nil],
546
+ [nil, nil, 'MDE_FENCED_START_EX_MATCH', nil, '', :fenced_start_ex_match,
547
+ '^`{3,}(?<shell>[^`\s]*) *(?<name>.*)$', nil],
548
+ [nil, nil, 'MDE_HEADING1_MATCH', nil, '', :heading1_match, '^# *(?<name>[^#]*?) *$', nil],
549
+ [nil, nil, 'MDE_HEADING2_MATCH', nil, '', :heading2_match, '^## *(?<name>[^#]*?) *$', nil],
550
+ [nil, nil, 'MDE_HEADING3_MATCH', nil, '', :heading3_match, '^### *(?<name>.+?) *$', nil],
551
+ [nil, nil, 'MDE_MD_FILENAME_GLOB', nil, '', :md_filename_glob, '*.[Mm][Dd]', nil],
552
+ [nil, nil, 'MDE_MD_FILENAME_MATCH', nil, '', :md_filename_match, '.+\\.md', nil],
553
+ [nil, nil, 'MDE_SELECT_PAGE_HEIGHT', nil, '', :select_page_height, 12, nil]
554
+ # [nil, nil, 'MDE_', nil, '', nil, '', nil],
555
+ ]
556
+
557
+ summary_head + summary_body + summary_tail + env_vars
558
+ end
559
+
560
+ def menu_help
561
+ @option_parser.help
240
562
  end
241
563
 
242
564
  def option_exclude_blocks(opts, blocks)
243
- if opts[:exclude_matching_block_names]
244
- blocks.reject { |block| block[:name].match(/^\(.+\)$/) }
565
+ block_name_excluded_match = Regexp.new opts[:block_name_excluded_match]
566
+ if opts[:hide_blocks_by_name]
567
+ blocks.reject { |block| block[:name].match(block_name_excluded_match) }
245
568
  else
246
569
  blocks
247
570
  end
248
571
  end
249
572
 
573
+ ## post-parse options configuration
574
+ #
575
+ def options_finalize(rest)
576
+ ## position 0: file or folder (optional)
577
+ #
578
+ if (pos = rest.fetch(0, nil))&.present?
579
+ if Dir.exist?(pos)
580
+ @options[:path] = pos
581
+ elsif File.exist?(pos)
582
+ @options[:filename] = pos
583
+ else
584
+ raise "Invalid parameter: #{pos}"
585
+ end
586
+ end
587
+
588
+ ## position 1: block name (optional)
589
+ #
590
+ @options[:block_name] = rest.fetch(1, nil)
591
+ end
592
+
250
593
  def optsmerge(call_options = {}, options_block = nil)
251
- class_call_options = options.merge(call_options || {})
594
+ class_call_options = @options.merge(call_options || {})
252
595
  if options_block
253
596
  options_block.call class_call_options
254
597
  else
255
598
  class_call_options
256
- end.tap { |ret| puts "optsmerge() ret: #{ret.inspect}" if $pdebug }
599
+ end.tap_inspect
600
+ end
601
+
602
+ def output_execution_summary
603
+ return unless @options[:output_execution_summary]
604
+
605
+ fout_section 'summary', {
606
+ execute_aborted_at: @execute_aborted_at,
607
+ execute_completed_at: @execute_completed_at,
608
+ execute_error: @execute_error,
609
+ execute_error_message: @execute_error_message,
610
+ execute_files: @execute_files,
611
+ execute_options: @execute_options,
612
+ execute_started_at: @execute_started_at,
613
+ execute_script_filespec: @execute_script_filespec
614
+ }
615
+ end
616
+
617
+ def prompt_with_quit(prompt_text, items, opts = {})
618
+ exit_option = '* Exit'
619
+ sel = @prompt.select prompt_text,
620
+ items + (@options[:menu_with_exit] ? [exit_option] : []),
621
+ opts
622
+ sel == exit_option ? nil : sel
257
623
  end
258
624
 
259
625
  def read_configuration_file!(options, configuration_path)
260
- if File.exist?(configuration_path)
261
- # rubocop:disable Security/YAMLLoad
262
- options.merge!((YAML.load(File.open(configuration_path)) || {})
263
- .transform_keys(&:to_sym))
264
- # rubocop:enable Security/YAMLLoad
265
- end
266
- options
626
+ return unless File.exist?(configuration_path)
627
+
628
+ # rubocop:disable Security/YAMLLoad
629
+ options.merge!((YAML.load(File.open(configuration_path)) || {})
630
+ .transform_keys(&:to_sym))
631
+ # rubocop:enable Security/YAMLLoad
267
632
  end
268
633
 
269
634
  def recursively_required(table, reqs)
@@ -278,231 +643,211 @@ module MarkdownExec
278
643
  end
279
644
  .compact
280
645
  .flatten(1)
281
- .tap { |_ret| puts "recursively_required() rem: #{rem.inspect}" if $pdebug }
646
+ .tap_inspect(name: 'rem')
282
647
  end
283
- all.tap { |ret| puts "recursively_required() ret: #{ret.inspect}" if $pdebug }
648
+ all.tap_inspect
284
649
  end
285
650
 
286
651
  def run
287
652
  ## default configuration
288
653
  #
289
- options = {
290
- mdheadings: true,
291
- list_blocks: false,
292
- list_docs: false
293
- }
654
+ @options = base_options
294
655
 
295
- ## post-parse options configuration
656
+ ## read local configuration file
296
657
  #
297
- options_finalize = ->(_options) {}
658
+ read_configuration_file! @options, ".#{MarkdownExec::APP_NAME.downcase}.yml"
298
659
 
299
- # read local configuration file
300
- #
301
- read_configuration_file! options, ".#{MarkdownExec::APP_NAME.downcase}.yml"
302
-
303
- option_parser = OptionParser.new do |opts|
660
+ @option_parser = option_parser = OptionParser.new do |opts|
304
661
  executable_name = File.basename($PROGRAM_NAME)
305
662
  opts.banner = [
306
- "#{MarkdownExec::APP_NAME} - #{MarkdownExec::APP_DESC} (#{MarkdownExec::VERSION})",
307
- "Usage: #{executable_name} [filename or path] [options]"
663
+ "#{MarkdownExec::APP_NAME}" \
664
+ " - #{MarkdownExec::APP_DESC} (#{MarkdownExec::VERSION})",
665
+ "Usage: #{executable_name} [path] [filename] [options]"
308
666
  ].join("\n")
309
667
 
310
- ## menu top: items appear in reverse order added
311
- #
312
- opts.on('--config PATH', 'Read configuration file') do |value|
313
- read_configuration_file! options, value
314
- end
315
-
316
- ## menu body: items appear in order added
317
- #
318
- opts.on('-f RELATIVE', '--filename', 'Name of document') do |value|
319
- options[:filename] = value
320
- end
321
-
322
- opts.on('-p PATH', '--path', 'Path to documents') do |value|
323
- options[:folder] = value
324
- end
325
-
326
- opts.on('--list-blocks', 'List blocks') do |_value|
327
- options[:list_blocks] = true
328
- end
329
-
330
- opts.on('--list-docs', 'List docs in current folder') do |_value|
331
- options[:list_docs] = true
332
- end
333
-
334
- ## menu bottom: items appear in order added
335
- #
336
- opts.on_tail('-h', '--help', 'App help') do |_value|
337
- fout option_parser.help
338
- exit
339
- end
340
-
341
- opts.on_tail('-v', '--version', 'App version') do |_value|
342
- fout MarkdownExec::VERSION
343
- exit
344
- end
345
-
346
- opts.on_tail('-x', '--exit', 'Exit app') do |_value|
347
- exit
348
- end
349
-
350
- opts.on_tail('-0', 'Show configuration') do |_v|
351
- options_finalize.call options
352
- fout options.to_yaml
668
+ menu_data
669
+ .map do |long_name, short_name, _env_var, arg_name, description, opt_name, default, proc1| # rubocop:disable Metrics/ParameterLists
670
+ next unless long_name.present? || short_name.present?
671
+
672
+ opts.on(*[if long_name.present?
673
+ "--#{long_name}#{arg_name.present? ? " #{arg_name}" : ''}"
674
+ end,
675
+ short_name.present? ? "-#{short_name}" : nil,
676
+ [description,
677
+ default.present? ? "[#{value_for_cli default}]" : nil].compact.join(' '),
678
+ lambda { |value|
679
+ ret = proc1.call(value)
680
+ options[opt_name] = ret if opt_name
681
+ ret
682
+ }].compact)
353
683
  end
354
684
  end
355
685
  option_parser.load # filename defaults to basename of the program without suffix in a directory ~/.options
356
686
  option_parser.environment # env defaults to the basename of the program.
357
687
  rest = option_parser.parse! # (into: options)
358
- options_finalize.call options
359
688
 
360
- if rest.fetch(0, nil)&.present?
361
- if Dir.exist?(rest[0])
362
- options[:folder] = rest[0]
363
- elsif File.exist?(rest[0])
364
- options[:filename] = rest[0]
365
- end
366
- end
367
-
368
- ## process
369
- #
370
- options.merge!(
371
- {
372
- approve: true,
373
- bash: true,
374
- display: true,
375
- exclude_expect_blocks: true,
376
- exclude_matching_block_names: true,
377
- execute: true,
378
- prompt: 'Execute',
379
- struct: true
380
- }
381
- )
382
- mp = MarkParse.new options
689
+ options_finalize rest
383
690
 
384
- ## show
385
- #
386
- if options[:list_docs]
387
- fout mp.list_files_per_options options
388
- return
389
- end
691
+ exec_block options, options[:block_name]
692
+ end
390
693
 
391
- if options[:list_blocks]
392
- fout (mp.list_files_per_options(options).map do |file|
393
- mp.make_block_labels(filename: file, struct: true)
394
- end).flatten(1)
395
- return
396
- end
694
+ def run_last_script
695
+ filename = Dir.glob(File.join(@options[:saved_script_folder],
696
+ @options[:saved_script_glob])).sort[0..(options[:list_count] - 1)].last
697
+ filename.tap_inspect name: filename
698
+ mf = filename.match(/#{@options[:saved_script_filename_prefix]}_(?<time>[0-9\-]+)_(?<file>.+)_(?<block>.+)\.sh/)
699
+
700
+ @options[:block_name] = mf[:block]
701
+ @options[:filename] = "#{mf[:file]}.md" ### other extensions
702
+ @options[:save_executed_script] = false
703
+ select_and_approve_block
704
+ save_execution_output
705
+ output_execution_summary
706
+ end
397
707
 
398
- mp.select_block(
399
- bash: true,
400
- filename: select_md_file(list_files_per_options(options)),
401
- struct: true
402
- )
708
+ def save_execution_output
709
+ return unless @options[:save_execution_output]
710
+
711
+ fne = File.basename(@options[:filename], '.*')
712
+ @options[:logged_stdout_filename] =
713
+ "#{[@options[:logged_stdout_filename_prefix], Time.now.utc.strftime('%F-%H-%M-%S'), fne,
714
+ @options[:block_name]].join('_')}.out.txt"
715
+ @options[:logged_stdout_filespec] = File.join @options[:saved_stdout_folder], @options[:logged_stdout_filename]
716
+ @logged_stdout_filespec = @options[:logged_stdout_filespec]
717
+ dirname = File.dirname(@options[:logged_stdout_filespec])
718
+ Dir.mkdir dirname unless File.exist?(dirname)
719
+ File.write(@options[:logged_stdout_filespec], @execute_files&.fetch(0, ''))
720
+ # @options[:logged_stderr_filename] =
721
+ # "#{[@options[:logged_stdout_filename_prefix], Time.now.utc.strftime('%F-%H-%M-%S'), fne,
722
+ # @options[:block_name]].join('_')}.err.txt"
723
+ # @options[:logged_stderr_filespec] = File.join @options[:saved_stdout_folder], @options[:logged_stderr_filename]
724
+ # @logged_stderr_filespec = @options[:logged_stderr_filespec]
725
+ # File.write(@options[:logged_stderr_filespec], @execute_files&.fetch(1, ''))
403
726
  end
404
727
 
405
- def select_block(call_options = {}, &options_block)
728
+ def select_and_approve_block(call_options = {}, &options_block)
406
729
  opts = optsmerge call_options, options_block
730
+ blocks_in_file = list_blocks_in_file(opts.merge(struct: true))
407
731
 
408
- blocks = list_blocks_in_file(opts.merge(struct: true))
732
+ unless opts[:block_name].present?
733
+ pt = (opts[:prompt_select_block]).to_s
734
+ blocks_in_file.each { |block| block.merge! label: make_block_label(block, opts) }
735
+ block_labels = option_exclude_blocks(opts, blocks_in_file).map { |block| block[:label] }
409
736
 
410
- prompt = TTY::Prompt.new(interrupt: :exit)
411
- pt = "#{opts.fetch(:prompt, nil) || 'Pick one'}:"
737
+ return nil if block_labels.count.zero?
412
738
 
413
- # blocks.map do |block|
414
- # next if opts[:exclude_matching_block_names] && block[:name].match(%r{^\(.+\)$})
415
- # block
416
- # end.compact.tap { |ret| puts "list_blocks_in_file() ret: #{ret.inspect}" if $pdebug }
739
+ sel = prompt_with_quit pt, block_labels, per_page: opts[:select_page_height]
740
+ return nil if sel.nil?
417
741
 
418
- blocks.each { |block| block.merge! label: make_block_label(block, opts) }
419
- # block_labels = blocks.map { |block| block[:label] }
420
- block_labels = option_exclude_blocks(opts, blocks).map { |block| block[:label] }
421
-
422
- if opts[:preview_options]
423
- select_per_page = 3
424
- block_labels.each do |bn|
425
- fout " - #{bn}"
426
- end
427
- else
428
- select_per_page = SELECT_PAGE_HEIGHT
742
+ label_block = blocks_in_file.select { |block| block[:label] == sel }.fetch(0, nil)
743
+ opts[:block_name] = @options[:block_name] = label_block[:name]
429
744
  end
430
745
 
431
- return nil if block_labels.count.zero?
432
-
433
- sel = prompt.select(pt, block_labels, per_page: select_per_page)
434
-
435
- label_block = blocks.select { |block| block[:label] == sel }.fetch(0, nil)
436
- sel = label_block[:name]
437
-
438
- cbs = list_recursively_required_blocks(blocks, sel)
439
-
440
- ## display code blocks for approval
441
- #
442
- cbs.each { |cb| fout cb } if opts[:display] || opts[:approve]
443
-
444
- allow = true
445
- allow = prompt.yes? 'Process?' if opts[:approve]
746
+ approve_block opts, blocks_in_file
747
+ end
446
748
 
447
- selected = get_block_by_name blocks, sel
448
- if allow && opts[:execute]
749
+ def select_md_file(files_ = nil)
750
+ opts = options
751
+ files = files_ || list_markdown_files_in_path
752
+ if files.count == 1
753
+ files[0]
754
+ elsif files.count >= 2
755
+ prompt_with_quit opts[:prompt_select_md].to_s, files, per_page: opts[:select_page_height]
756
+ end
757
+ end
449
758
 
450
- cmd2 = cbs.flatten.join("\n")
759
+ def select_recent_script
760
+ filename = prompt_with_quit @options[:prompt_select_md].to_s, list_recent_scripts,
761
+ per_page: @options[:select_page_height]
762
+ return if filename.nil?
451
763
 
452
- Open3.popen3(cmd2) do |stdin, stdout, stderr|
453
- stdin.close_write
454
- begin
455
- files = [stdout, stderr]
764
+ mf = filename.match(/#{@options[:saved_script_filename_prefix]}_(?<time>[0-9\-]+)_(?<file>.+)_(?<block>.+)\.sh/)
456
765
 
457
- until all_at_eof(files)
458
- ready = IO.select(files)
766
+ @options[:block_name] = mf[:block]
767
+ @options[:filename] = "#{mf[:file]}.md" ### other extensions
768
+ select_and_approve_block(
769
+ bash: true,
770
+ save_executed_script: false,
771
+ struct: true
772
+ )
773
+ save_execution_output
774
+ output_execution_summary
775
+ end
459
776
 
460
- next unless ready
777
+ def sorted_keys(hash1)
778
+ hash1.keys.sort.to_h { |k| [k, hash1[k]] }
779
+ end
461
780
 
462
- readable = ready[0]
463
- # writable = ready[1]
464
- # exceptions = ready[2]
781
+ def summarize_block(headings, title)
782
+ { headings: headings, name: title, title: title }
783
+ end
465
784
 
466
- readable.each do |f|
467
- print f.read_nonblock(BLOCK_SIZE)
468
- rescue EOFError #=> e
469
- # do nothing at EOF
470
- end
471
- end
472
- rescue IOError => e
473
- fout "IOError: #{e}"
474
- end
475
- end
785
+ def update_options(opts = {}, over: true)
786
+ if over
787
+ @options = @options.merge opts
788
+ else
789
+ @options.merge! opts
476
790
  end
477
-
478
- selected[:name]
791
+ @options.tap_inspect format: :yaml
479
792
  end
480
793
 
481
- def select_md_file(files_ = nil)
482
- opts = options
483
- files = files_ || list_markdown_files_in_folder
484
- if files.count == 1
485
- sel = files[0]
486
- elsif files.count >= 2
794
+ def value_for_cli(value)
795
+ case value.class.to_s
796
+ when 'String'
797
+ "'#{value}'"
798
+ when 'FalseClass', 'TrueClass'
799
+ value ? '1' : '0'
800
+ when 'Integer'
801
+ value
802
+ else
803
+ value.to_s
804
+ end
805
+ end
487
806
 
488
- if opts[:preview_options]
489
- select_per_page = 3
490
- files.each do |file|
491
- fout " - #{file}"
492
- end
493
- else
494
- select_per_page = SELECT_PAGE_HEIGHT
495
- end
807
+ def value_for_hash(value, default = nil)
808
+ return default if value.nil?
496
809
 
497
- prompt = TTY::Prompt.new
498
- sel = prompt.select("#{opts.fetch(:prompt, 'Pick one')}:", files, per_page: select_per_page)
810
+ case value.class.to_s
811
+ when 'String', 'Integer', 'FalseClass', 'TrueClass'
812
+ value
813
+ when value.empty?
814
+ default
815
+ else
816
+ value.to_s
499
817
  end
818
+ end
500
819
 
501
- sel
820
+ def value_for_yaml(value)
821
+ return default if value.nil?
822
+
823
+ case value.class.to_s
824
+ when 'String'
825
+ "'#{value}'"
826
+ when 'Integer'
827
+ value
828
+ when 'FalseClass', 'TrueClass'
829
+ value ? true : false
830
+ when value.empty?
831
+ default
832
+ else
833
+ value.to_s
834
+ end
502
835
  end
503
836
 
504
- def summarize_block(headings, title)
505
- { headings: headings, name: title, title: title }
837
+ def write_command_file(opts, required_blocks)
838
+ fne = File.basename(opts[:filename], '.*')
839
+ opts[:saved_script_filename] =
840
+ "#{[opts[:saved_script_filename_prefix], Time.now.utc.strftime('%F-%H-%M-%S'), fne,
841
+ opts[:block_name]].join('_')}.sh"
842
+ @options[:saved_filespec] = File.join opts[:saved_script_folder], opts[:saved_script_filename]
843
+ @execute_script_filespec = @options[:saved_filespec]
844
+ dirname = File.dirname(@options[:saved_filespec])
845
+ Dir.mkdir dirname unless File.exist?(dirname)
846
+ File.write(@options[:saved_filespec], "#!/usr/bin/env bash\n" \
847
+ "# file_name: #{opts[:filename]}\n" \
848
+ "# block_name: #{opts[:block_name]}\n" \
849
+ "# time: #{Time.now.utc}\n" \
850
+ "#{required_blocks.flatten.join("\n")}\n")
506
851
  end
507
852
  end
508
853
  end