markdown_exec 0.2.1 → 0.2.5

Sign up to get free protection for your applications and to get access to all the features.
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