markdown_exec 0.1.3 → 0.2.3

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