howzit 1.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.
@@ -0,0 +1,1146 @@
1
+ module Howzit
2
+ # Primary Class for this module
3
+ class BuildNotes
4
+ attr_accessor :arguments, :metadata
5
+
6
+ def topics
7
+ @topics ||= read_help
8
+ end
9
+
10
+ # If either mdless or mdcat are installed, use that for highlighting
11
+ # markdown
12
+ def which_highlighter
13
+ if @options[:highlighter] =~ /auto/i
14
+ highlighters = %w[mdcat mdless]
15
+ highlighters.delete_if(&:nil?).select!(&:available?)
16
+ return nil if highlighters.empty?
17
+
18
+ hl = highlighters.first
19
+ args = case hl
20
+ when 'mdless'
21
+ '--no-pager'
22
+ end
23
+
24
+ [hl, args].join(' ')
25
+ else
26
+ hl = @options[:highlighter].split(/ /)[0]
27
+ if hl.available?
28
+ @options[:highlighter]
29
+ else
30
+ warn 'Specified highlighter not found, switching to auto' if @options[:log_level] < 2
31
+ @options[:highlighter] = 'auto'
32
+ which_highlighter
33
+ end
34
+ end
35
+ end
36
+
37
+ # When pagination is enabled, find the best (in my opinion) option,
38
+ # favoring environment settings
39
+ def which_pager
40
+ if @options[:pager] =~ /auto/i
41
+ pagers = [ENV['GIT_PAGER'], ENV['PAGER'],
42
+ 'bat', 'less', 'more', 'cat', 'pager']
43
+ pagers.delete_if(&:nil?).select!(&:available?)
44
+ return nil if pagers.empty?
45
+
46
+ pg = pagers.first
47
+ args = case pg
48
+ when 'delta'
49
+ '--pager="less -FXr"'
50
+ when /^(less|more)$/
51
+ '-FXr'
52
+ when 'bat'
53
+ if @options[:highlight]
54
+ '--language Markdown --style plain --pager="less -FXr"'
55
+ else
56
+ '--style plain --pager="less -FXr"'
57
+ end
58
+ else
59
+ ''
60
+ end
61
+
62
+ [pg, args].join(' ')
63
+ else
64
+ pg = @options[:pager].split(/ /)[0]
65
+ if pg.available?
66
+ @options[:pager]
67
+ else
68
+ warn 'Specified pager not found, switching to auto' if @options[:log_level] < 2
69
+ @options[:pager] = 'auto'
70
+ which_pager
71
+ end
72
+ end
73
+ end
74
+
75
+ # Paginate the output
76
+ def page(text)
77
+ read_io, write_io = IO.pipe
78
+
79
+ input = $stdin
80
+
81
+ pid = Kernel.fork do
82
+ write_io.close
83
+ input.reopen(read_io)
84
+ read_io.close
85
+
86
+ # Wait until we have input before we start the pager
87
+ IO.select [input]
88
+
89
+ pager = which_pager
90
+ begin
91
+ exec(pager)
92
+ rescue SystemCallError => e
93
+ @log.error(e)
94
+ exit 1
95
+ end
96
+ end
97
+
98
+ read_io.close
99
+ write_io.write(text)
100
+ write_io.close
101
+
102
+ _, status = Process.waitpid2(pid)
103
+ status.success?
104
+ end
105
+
106
+ # print output to terminal
107
+ def show(string, opts = {})
108
+ options = {
109
+ color: true,
110
+ highlight: false,
111
+ paginate: false,
112
+ wrap: 0
113
+ }
114
+
115
+ options.merge!(opts)
116
+
117
+ string = string.uncolor unless options[:color]
118
+
119
+ pipes = ''
120
+ if options[:highlight]
121
+ hl = which_highlighter
122
+ pipes = "|#{hl}" if hl
123
+ end
124
+
125
+ output = `echo #{Shellwords.escape(string.strip)}#{pipes}`
126
+
127
+ if options[:paginate]
128
+ page(output)
129
+ else
130
+ output.gsub!(/^╌/, '\e]1337;SetMark\a╌') if ENV['TERM_PROGRAM'] =~ /^iTerm/ && !options[:run]
131
+ puts output
132
+ end
133
+ end
134
+
135
+ def color_single_options(choices = %w[y n])
136
+ out = []
137
+ choices.each do |choice|
138
+ case choice
139
+ when /[A-Z]/
140
+ out.push("\e[1;32m#{choice}\e[0;32m")
141
+ else
142
+ out.push(choice)
143
+ end
144
+ end
145
+ "\e[0;32m[#{out.join('/')}]\e[0m"
146
+ end
147
+
148
+ # Create a buildnotes skeleton
149
+ def create_note
150
+ trap('SIGINT') do
151
+ warn "\nCanceled"
152
+ exit!
153
+ end
154
+ # First make sure there isn't already a buildnotes file
155
+ if note_file
156
+ system 'stty cbreak'
157
+ fname = "\e[1;33m#{note_file}\e[1;37m"
158
+ yn = color_single_options(%w[y N])
159
+ $stdout.syswrite "#{fname} exists and appears to be a build note, continue anyway #{yn}\e[1;37m? \e[0m"
160
+ res = $stdin.sysread 1
161
+ res.chomp!
162
+ puts
163
+ system 'stty cooked'
164
+
165
+ unless res =~ /y/i
166
+ puts 'Canceled'
167
+ Process.exit 0
168
+ end
169
+ end
170
+
171
+ title = File.basename(Dir.pwd)
172
+ printf "\e[1;37mProject name \e[0;32m[#{title}]\e[1;37m: \e[0m"
173
+ input = STDIN.gets.chomp
174
+ title = input unless input.empty?
175
+
176
+ summary = ''
177
+ printf "\e[1;37mProject summary: \e[0m"
178
+ input = STDIN.gets.chomp
179
+ summary = input unless input.empty?
180
+
181
+ filename = 'buildnotes.md'
182
+ printf "\e[1;37mBuild notes filename (must begin with 'howzit' or 'build')\n\e[0;32m[#{filename}]\e[1;37m: \e[0m"
183
+ input = STDIN.gets.chomp
184
+ filename = input unless input.empty?
185
+
186
+ note = <<~EOBUILDNOTES
187
+ # #{title}
188
+
189
+ #{summary}
190
+
191
+ ## File Structure
192
+
193
+ Where are the main editable files? Is there a dist/build folder that should be ignored?
194
+
195
+ ## Build
196
+
197
+ What build system/parameters does this use?
198
+
199
+ @run(./build command)
200
+
201
+ ## Deploy
202
+
203
+ What are the procedures/commands to deploy this project?
204
+
205
+ ## Other
206
+
207
+ Version control notes, additional gulp/rake/make/etc tasks...
208
+
209
+ EOBUILDNOTES
210
+
211
+ if File.exist?(filename)
212
+ system 'stty cbreak'
213
+ yn = color_single_options(%w[y N])
214
+ file = "\e[1;33m#{filename}"
215
+ $stdout.syswrite "\e[1;37mAre you absolutely sure you want to overwrite #{file} #{yn}\e[1;37m? \e[0m"
216
+ res = $stdin.sysread 1
217
+ res.chomp!
218
+ puts
219
+ system 'stty cooked'
220
+
221
+ unless res =~ /y/i
222
+ puts 'Canceled'
223
+ Process.exit 0
224
+ end
225
+ end
226
+
227
+ File.open(filename, 'w') do |f|
228
+ f.puts note
229
+ puts "Build notes for #{title} written to #{filename}"
230
+ end
231
+ end
232
+
233
+ # Make a fancy title line for the topic
234
+ def format_header(title, opts = {})
235
+ options = {
236
+ hr: "\u{254C}",
237
+ color: '1;32',
238
+ border: '0'
239
+ }
240
+
241
+ options.merge!(opts)
242
+
243
+ cols = `tput cols`.strip.to_i
244
+ cols = @options[:wrap] if (@options[:wrap]).positive? && cols > @options[:wrap]
245
+ title = "\e[#{options[:border]}m#{options[:hr]}#{options[:hr]}( \e[#{options[:color]}m#{title}\e[#{options[:border]}m )"
246
+ tail = options[:hr] * (cols - title.uncolor.length)
247
+ "#{title}#{tail}\e[0m"
248
+ end
249
+
250
+ def os_open(command)
251
+ os = RbConfig::CONFIG['target_os']
252
+ out = "\e[1;32mOpening \e[3;37m#{command}"
253
+ case os
254
+ when /darwin.*/i
255
+ warn "#{out} (macOS)\e[0m" if @options[:log_level] < 2
256
+ `open #{Shellwords.escape(command)}`
257
+ when /mingw|mswin/i
258
+ warn "#{out} (Windows)\e[0m" if @options[:log_level] < 2
259
+ `start #{Shellwords.escape(command)}`
260
+ else
261
+ if 'xdg-open'.available?
262
+ warn "#{out} (Linux)\e[0m" if @options[:log_level] < 2
263
+ `xdg-open #{Shellwords.escape(command)}`
264
+ else
265
+ warn out if @options[:log_level] < 2
266
+ warn 'Unable to determine executable for `open`.'
267
+ end
268
+ end
269
+ end
270
+
271
+ def grep_topics(pat)
272
+ matching_topics = []
273
+ topics.each do |topic, content|
274
+ if content =~ /#{pat}/i || topic =~ /#{pat}/i
275
+ matching_topics.push(topic)
276
+ end
277
+ end
278
+ matching_topics
279
+ end
280
+
281
+ # Handle run command, execute directives
282
+ def run_topic(key)
283
+ output = []
284
+ tasks = 0
285
+ if topics[key] =~ /(@(include|run|copy|open|url)\((.*?)\)|`{3,}run)/i
286
+ directives = topics[key].scan(/(?:@(include|run|copy|open|url)\((.*?)\)|(`{3,})run(?: +([^\n]+))?(.*?)\3)/mi)
287
+
288
+ tasks += directives.length
289
+ directives.each do |c|
290
+ if c[0].nil?
291
+ title = c[3] ? c[3].strip : ''
292
+ warn "\e[1;32mRunning block \e[3;37m#{title}\e[0m" if @options[:log_level] < 2
293
+ block = c[4].strip
294
+ script = Tempfile.new('howzit_script')
295
+ begin
296
+ script.write(block)
297
+ script.close
298
+ File.chmod(0777, script.path)
299
+ system(%(/bin/sh -c "#{script.path}"))
300
+ ensure
301
+ script.close
302
+ script.unlink
303
+ end
304
+ else
305
+ cmd = c[0]
306
+ obj = c[1]
307
+ case cmd
308
+ when /include/i
309
+ matches = match_topic(obj)
310
+ if matches.empty?
311
+ warn "No topic match for @include(#{search})"
312
+ else
313
+ if @included.include?(matches[0])
314
+ warn "\e[1;33mTasks from \e[3;37m#{matches[0]} already included, skipping\e[0m" if @options[:log_level] < 2
315
+ else
316
+ warn "\e[1;33mIncluding tasks from \e[3;37m#{matches[0]}\e[0m" if @options[:log_level] < 2
317
+ process_topic(matches[0], true)
318
+ warn "\e[1;33mEnd include \e[3;37m#{matches[0]}\e[0m" if @options[:log_level] < 2
319
+ end
320
+ end
321
+ when /run/i
322
+ warn "\e[1;32mRunning \e[3;37m#{obj}\e[0m" if @options[:log_level] < 2
323
+ system(obj)
324
+ when /copy/i
325
+ warn "\e[1;32mCopied \e[3;37m#{obj}\e[1;32m to clipboard\e[0m" if @options[:log_level] < 2
326
+ `echo #{Shellwords.escape(obj)}'\\c'|pbcopy`
327
+ when /open|url/i
328
+ os_open(obj)
329
+ end
330
+ end
331
+ end
332
+ else
333
+ warn "\e[0;31m--run: No \e[1;31m@directive\e[0;31;40m found in \e[1;37m#{key}\e[0m"
334
+ end
335
+ output.push("Ran #{tasks} #{tasks == 1 ? 'task' : 'tasks'}") if @options[:log_level] < 2
336
+ end
337
+
338
+ # Output a topic with fancy title and bright white text.
339
+ def output_topic(key, options = {})
340
+ defaults = { single: false, header: true }
341
+ opt = defaults.merge(options)
342
+
343
+ output = []
344
+ if opt[:header]
345
+ output.push(format_header(key))
346
+ output.push('')
347
+ end
348
+ topic = topics[key].strip
349
+ topic.gsub!(/(?mi)^(`{3,})run *([^\n]*)[\s\S]*?\n\1\s*$/, '@@@run \2') unless @options[:show_all_code]
350
+ topic.split(/\n/).each do |l|
351
+ case l
352
+ when /@include\((.*?)\)/
353
+
354
+ m = Regexp.last_match
355
+ matches = match_topic(m[1])
356
+ unless matches.empty?
357
+ if opt[:single]
358
+ title = "From #{matches[0]}:"
359
+ color = '33;40'
360
+ rule = '30;40'
361
+ else
362
+ title = "Include #{matches[0]}"
363
+ color = '33;40'
364
+ rule = '0'
365
+ end
366
+ output.push(format_header("#{'> ' * @nest_level}#{title}", { color: color, hr: '.', border: rule })) unless @included.include?(matches[0])
367
+
368
+ if opt[:single]
369
+ if @included.include?(matches[0])
370
+ output.push(format_header("#{'> ' * @nest_level}#{title} included above", { color: color, hr: '.', border: rule }))
371
+ else
372
+ @nest_level += 1
373
+ output.concat(output_topic(matches[0], {single: true, header: false}))
374
+ @nest_level -= 1
375
+ end
376
+ output.push(format_header("#{'> ' * @nest_level}...", { color: color, hr: '.', border: rule })) unless @included.include?(matches[0])
377
+ end
378
+ @included.push(matches[0])
379
+ end
380
+
381
+ when /@(run|copy|open|url|include)\((.*?)\)/
382
+ m = Regexp.last_match
383
+ cmd = m[1]
384
+ obj = m[2]
385
+ icon = case cmd
386
+ when 'run'
387
+ "\u{25B6}"
388
+ when 'copy'
389
+ "\u{271A}"
390
+ when /open|url/
391
+ "\u{279A}"
392
+ end
393
+ output.push("\e[1;35;40m#{icon} \e[3;37;40m#{obj}\e[0m")
394
+ when /(`{3,})run *(.*?)$/i
395
+ m = Regexp.last_match
396
+ desc = m[2].length.positive? ? "Block: #{m[2]}" : 'Code Block'
397
+ output.push("\e[1;35;40m\u{25B6} \e[3;37;40m#{desc}\e[0m\n```")
398
+ when /@@@run *(.*?)$/i
399
+ m = Regexp.last_match
400
+ desc = m[1].length.positive? ? "Block: #{m[1]}" : 'Code Block'
401
+ output.push("\e[1;35;40m\u{25B6} \e[3;37;40m#{desc}\e[0m")
402
+ else
403
+ l.wrap!(@options[:wrap]) if (@options[:wrap]).positive?
404
+ output.push(l)
405
+ end
406
+ end
407
+ output.push('')
408
+ end
409
+
410
+ def process_topic(key, run, single = false)
411
+ # Handle variable replacement
412
+ content = topics[key]
413
+ unless @arguments.empty?
414
+ content.gsub!(/\$(\d+)/) do |m|
415
+ idx = m[1].to_i - 1
416
+ @arguments.length > idx ? @arguments[idx] : m
417
+ end
418
+ content.gsub!(/\$[@*]/, Shellwords.join(@arguments))
419
+ end
420
+
421
+ output = if run
422
+ run_topic(key)
423
+ else
424
+ output_topic(key, {single: single})
425
+ end
426
+ output.nil? ? '' : output.join("\n")
427
+ end
428
+
429
+ # Output a list of topic titles
430
+ def list_topics
431
+ output = []
432
+ output.push("\e[1;32mTopics:\e[0m\n")
433
+ topics.each_key do |title|
434
+ output.push("- \e[1;37m#{title}\e[0m")
435
+ end
436
+ output.join("\n")
437
+ end
438
+
439
+ # Output a list of topic titles for shell completion
440
+ def list_topic_titles
441
+ topics.keys.join("\n")
442
+ end
443
+
444
+ def get_note_title(filename, truncate = 0)
445
+ title = nil
446
+ help = IO.read(filename).strip
447
+ title = help.match(/(?:^(\S.*?)(?=\n==)|^# ?(.*?)$)/)
448
+ title = if title
449
+ title[1].nil? ? title[2] : title[1]
450
+ else
451
+ filename.sub(/(\.\w+)?$/, '')
452
+ end
453
+
454
+ title && truncate.positive? ? title.trunc(truncate) : title
455
+ end
456
+
457
+ def list_runnable_titles
458
+ output = []
459
+ topics.each do |title, sect|
460
+ runnable = false
461
+ sect.split(/\n/).each do |l|
462
+ if l =~ /(@(run|copy|open|url)\((.*?)\)|`{3,}run)/
463
+ runnable = true
464
+ break
465
+ end
466
+ end
467
+ output.push(title) if runnable
468
+ end
469
+ output.join("\n")
470
+ end
471
+
472
+ def list_runnable
473
+ output = []
474
+ output.push(%(\e[1;32m"Runnable" Topics:\e[0m\n))
475
+ topics.each do |title, sect|
476
+ s_out = []
477
+ lines = sect.split(/\n/)
478
+ lines.each do |l|
479
+ case l
480
+ when /@run\((.*?)\)(.*)?/
481
+ m = Regexp.last_match
482
+ run = m[2].strip.length.positive? ? m[2].strip : m[1]
483
+ s_out.push(" * run: #{run.gsub(/\\n/, '\​n')}")
484
+ when /@(copy|open|url)\((.*?)\)/
485
+ m = Regexp.last_match
486
+ s_out.push(" * #{m[1]}: #{m[2]}")
487
+ when /`{3,}run(.*)?/m
488
+ run = ' * run code block'
489
+ title = Regexp.last_match(1).strip
490
+ run += " (#{title})" if title.length.positive?
491
+ s_out.push(run)
492
+ end
493
+ end
494
+ unless s_out.empty?
495
+ output.push("- \e[1;37m#{title}\e[0m")
496
+ output.push(s_out.join("\n"))
497
+ end
498
+ end
499
+ output.join("\n")
500
+ end
501
+
502
+ def read_upstream
503
+ buildnotes = glob_upstream
504
+ topics_dict = {}
505
+ buildnotes.each do |path|
506
+ topics_dict = topics_dict.merge(read_help_file(path))
507
+ end
508
+ topics_dict
509
+ end
510
+
511
+ def ensure_requirements(template)
512
+ t_leader = IO.read(template).split(/^#/)[0].strip
513
+ if t_leader.length > 0
514
+ t_meta = t_leader.get_metadata
515
+ if t_meta.key?('required')
516
+ required = t_meta['required'].strip.split(/\s*,\s*/)
517
+ required.each do |req|
518
+ unless @metadata.keys.include?(req.downcase)
519
+ warn %(\e[0;31mERROR: Missing required metadata key from template '\e[1;37m#{File.basename(template, '.md')}\e[0;31m'\e[0m)
520
+ warn %(\e[0;31mPlease define \e[1;33m#{req.downcase}\e[0;31m in build notes\e[0m)
521
+ Process.exit 1
522
+ end
523
+ end
524
+ end
525
+ end
526
+ end
527
+
528
+ def get_template_topics(content)
529
+ leader = content.split(/^#/)[0].strip
530
+
531
+ template_topics = {}
532
+ if leader.length > 0
533
+ data = leader.get_metadata
534
+ @metadata = @metadata.merge(data)
535
+
536
+ if data.key?('template')
537
+ templates = data['template'].strip.split(/\s*,\s*/)
538
+ templates.each do |t|
539
+ tasks = nil
540
+ if t =~ /\[(.*?)\]$/
541
+ tasks = Regexp.last_match[1].split(/\s*,\s*/).map {|t| t.gsub(/\*/, '.*?')}
542
+ t = t.sub(/\[.*?\]$/, '').strip
543
+ end
544
+
545
+ t_file = t.sub(/(\.md)?$/, '.md')
546
+ template = File.join(template_folder, t_file)
547
+ if File.exist?(template)
548
+ ensure_requirements(template)
549
+
550
+ t_topics = read_help_file(template)
551
+ if tasks
552
+ tasks.each do |task|
553
+ t_topics.keys.each do |topic|
554
+ if topic =~ /^(.*?:)?#{task}$/i
555
+ template_topics[topic] = t_topics[topic]
556
+ end
557
+ end
558
+ end
559
+ else
560
+ template_topics = template_topics.merge(t_topics)
561
+ end
562
+ end
563
+ end
564
+ end
565
+ end
566
+ template_topics
567
+ end
568
+
569
+ # Read in the build notes file and output a hash of "Title" => contents
570
+ def read_help_file(path = nil)
571
+ filename = path.nil? ? note_file : path
572
+ topics_dict = {}
573
+ help = IO.read(filename)
574
+
575
+ help.gsub!(/@include\((.*?)\)/) do
576
+ m = Regexp.last_match
577
+ file = File.expand_path(m[1])
578
+ if File.exist?(file)
579
+ content = IO.read(file)
580
+ home = ENV['HOME']
581
+ short_path = File.dirname(file.sub(/^#{home}/, '~'))
582
+ prefix = "#{short_path}:"
583
+ parts = content.split(/^##+/)
584
+ parts.shift
585
+ content = '## ' + parts.join('## ')
586
+ content.gsub!(/^(##+ *)(?=\S)/, "\\1#{prefix}")
587
+ content
588
+ else
589
+ m[0]
590
+ end
591
+ end
592
+
593
+ template_topics = get_template_topics(help)
594
+
595
+ split = help.split(/^##+/)
596
+ split.slice!(0)
597
+ split.each do |sect|
598
+ next if sect.strip.empty?
599
+
600
+ lines = sect.split(/\n/)
601
+ title = lines.slice!(0).strip
602
+ prefix = ''
603
+ if path
604
+ if path =~ /#{template_folder}/
605
+ short_path = File.basename(path, '.md')
606
+ else
607
+ home = ENV['HOME']
608
+ short_path = File.dirname(path.sub(/^#{home}/, '~'))
609
+ prefix = "_from #{short_path}_\n\n"
610
+ end
611
+ title = "#{short_path}:#{title}"
612
+ end
613
+ topics_dict[title] = prefix + lines.join("\n").strip.render_template(@metadata)
614
+ end
615
+
616
+ template_topics.each do |title, content|
617
+ unless topics_dict.key?(title.sub(/^.+:/, ''))
618
+ topics_dict[title] = content
619
+ end
620
+ end
621
+
622
+ topics_dict
623
+ end
624
+
625
+ def read_help
626
+ topics = read_help_file
627
+ if @options[:include_upstream]
628
+ upstream_topics = read_upstream
629
+ upstream_topics.each do |topic, content|
630
+ unless topics.key?(topic.sub(/^.*?:/, ''))
631
+ topics[topic] = content
632
+ end
633
+ end
634
+ # topics = upstream_topics.merge(topics)
635
+ end
636
+ topics
637
+ end
638
+
639
+
640
+ def match_topic(search)
641
+ matches = []
642
+
643
+ rx = case @options[:matching]
644
+ when 'exact'
645
+ /^#{search}$/i
646
+ when 'beginswith'
647
+ /^#{search}/i
648
+ when 'fuzzy'
649
+ search = search.split(//).join('.*?') if @options[:matching] == 'fuzzy'
650
+ /#{search}/i
651
+ else
652
+ /#{search}/i
653
+ end
654
+
655
+ topics.each_key do |k|
656
+ matches.push(k) if k.downcase =~ rx
657
+ end
658
+ matches
659
+ end
660
+
661
+ def initialize(args)
662
+ flags = {
663
+ run: false,
664
+ list_topics: false,
665
+ list_topic_titles: false,
666
+ list_runnable: false,
667
+ list_runnable_titles: false,
668
+ title_only: false,
669
+ choose: false,
670
+ quiet: false,
671
+ verbose: false
672
+ }
673
+
674
+ defaults = {
675
+ color: true,
676
+ highlight: true,
677
+ paginate: true,
678
+ wrap: 0,
679
+ output_title: false,
680
+ highlighter: 'auto',
681
+ pager: 'auto',
682
+ matching: 'partial', # exact, partial, fuzzy, beginswith
683
+ show_all_on_error: false,
684
+ include_upstream: false,
685
+ show_all_code: false,
686
+ grep: nil,
687
+ log_level: 1 # 0: debug, 1: info, 2: warn, 3: error
688
+ }
689
+
690
+ @metadata = {}
691
+ @included = []
692
+ @nest_level = 0
693
+
694
+ parts = Shellwords.shelljoin(args).split(/ -- /)
695
+ args = parts[0] ? Shellwords.shellsplit(parts[0]) : []
696
+ @arguments = parts[1] ? Shellwords.shellsplit(parts[1]) : []
697
+
698
+ config = load_config(defaults)
699
+ @options = flags.merge(config)
700
+
701
+ OptionParser.new do |opts|
702
+ opts.banner = "Usage: #{__FILE__} [OPTIONS] [TOPIC]"
703
+ opts.separator ''
704
+ opts.separator 'Show build notes for the current project (buildnotes.md). Include a topic name to see just that topic, or no argument to display all.'
705
+ opts.separator ''
706
+ opts.separator 'Options:'
707
+
708
+ opts.on('-c', '--create', 'Create a skeleton build note in the current working directory') do
709
+ create_note
710
+ Process.exit 0
711
+ end
712
+
713
+ opts.on('-e', '--edit', "Edit buildnotes file in current working directory using #{File.basename(ENV['EDITOR'])}") do
714
+ edit_note
715
+ Process.exit 0
716
+ end
717
+
718
+ opts.on('--grep PATTERN', 'Display sections matching a search pattern') do |pat|
719
+ @options[:grep] = pat
720
+ end
721
+
722
+ opts.on('-L', '--list-completions', 'List topics for completion') do
723
+ @options[:list_topics] = true
724
+ @options[:list_topic_titles] = true
725
+ end
726
+
727
+ opts.on('-l', '--list', 'List available topics') do
728
+ @options[:list_topics] = true
729
+ end
730
+
731
+ opts.on('-m', '--matching TYPE', MATCHING_OPTIONS, 'Topics matching type', "(#{MATCHING_OPTIONS.join(', ')})") do |c|
732
+ @options[:matching] = c
733
+ end
734
+
735
+ opts.on('-R', '--list-runnable', 'List topics containing @ directives (verbose)') do
736
+ @options[:list_runnable] = true
737
+ end
738
+
739
+ opts.on('-r', '--run', 'Execute @run, @open, and/or @copy commands for given topic') do
740
+ @options[:run] = true
741
+ end
742
+
743
+ opts.on('-s', '--select', 'Select topic from menu') do
744
+ @options[:choose] = true
745
+ end
746
+
747
+ opts.on('-T', '--task-list', 'List topics containing @ directives (completion-compatible)') do
748
+ @options[:list_runnable] = true
749
+ @options[:list_runnable_titles] = true
750
+ end
751
+
752
+ opts.on('-t', '--title', 'Output title with build notes') do
753
+ @options[:output_title] = true
754
+ end
755
+
756
+ opts.on('-q', '--quiet', 'Silence info message') do
757
+ @options[:log_level] = 3
758
+ end
759
+
760
+ opts.on('-v', '--verbose', 'Show all messages') do
761
+ @options[:log_level] = 0
762
+ end
763
+
764
+ opts.on('-u', '--upstream', 'Traverse up parent directories for additional build notes') do
765
+ @options[:include_upstream] = true
766
+ end
767
+
768
+ opts.on('--show-code', 'Display the content of fenced run blocks') do
769
+ @options[:show_all_code] = true
770
+ end
771
+
772
+ opts.on('-w', '--wrap COLUMNS', 'Wrap to specified width (default 80, 0 to disable)') do |w|
773
+ @options[:wrap] = w.to_i
774
+ end
775
+
776
+ opts.on('--edit-config', "Edit configuration file using #{File.basename(ENV['EDITOR'])}") do
777
+ edit_config(defaults)
778
+ Process.exit 0
779
+ end
780
+
781
+ opts.on('--title-only', 'Output title only') do
782
+ @options[:output_title] = true
783
+ @options[:title_only] = true
784
+ end
785
+
786
+ opts.on('--templates', 'List available templates') do
787
+ Dir.chdir(template_folder)
788
+ Dir.glob('*.md').each do |file|
789
+ template = File.basename(file, '.md')
790
+ puts "\e[7;30;45mtemplate: \e[7;33;40m#{template}\e[0m"
791
+ puts "\e[1;30m[\e[1;37mtasks\e[1;30m]──────────────────────────────────────┐\e[0m"
792
+ metadata = file.extract_metadata
793
+ topics = read_help_file(file)
794
+ topics.keys.each do |topic|
795
+ puts " \e[1;30m│\e[1;37m-\e[0m \e[1;36;40m#{template}:#{topic.sub(/^.*?:/, '')}\e[0m"
796
+ end
797
+ if metadata.size > 0
798
+ meta = []
799
+ meta << metadata['required'].split(/\s*,\s*/).map {|m| "*\e[1;37m#{m}\e[0;37m" } if metadata.key?('required')
800
+ meta << metadata['optional'].split(/\s*,\s*/).map {|m| "#{m}" } if metadata.key?('optional')
801
+ puts "\e[1;30m[\e[1;34mmeta\e[1;30m]───────────────────────────────────────┤\e[0m"
802
+ puts " \e[1;30m│\e[1;37m \e[0;37m#{meta.join(", ")}\e[0m"
803
+ end
804
+ puts " \e[1;30m└───────────────────────────────────────────┘\e[0m"
805
+ end
806
+ Process.exit 0
807
+ end
808
+
809
+ opts.on('--[no-]color', 'Colorize output (default on)') do |c|
810
+ @options[:color] = c
811
+ @options[:highlight] = false unless c
812
+ end
813
+
814
+ opts.on('--[no-]md-highlight', 'Highlight Markdown syntax (default on), requires mdless or mdcat') do |m|
815
+ @options[:highlight] = @options[:color] ? m : false
816
+ end
817
+
818
+ opts.on('--[no-]pager', 'Paginate output (default on)') do |p|
819
+ @options[:paginate] = p
820
+ end
821
+
822
+ opts.on('-h', '--help', 'Display this screen') do
823
+ puts opts
824
+ Process.exit 0
825
+ end
826
+
827
+ opts.on('-v', '--version', 'Display version number') do
828
+ puts "Howzit v#{VERSION}"
829
+ Process.exit 0
830
+ end
831
+ end.parse!(args)
832
+
833
+ process(args)
834
+ end
835
+
836
+ def edit_note
837
+ raise 'No EDITOR variable defined in environment' if ENV['EDITOR'].nil?
838
+
839
+ if note_file.nil?
840
+ system 'stty cbreak'
841
+ yn = color_single_options(%w[Y n])
842
+ $stdout.syswrite "No build notes file found, create one #{yn}? "
843
+ res = $stdin.sysread 1
844
+ puts
845
+ system 'stty cooked'
846
+
847
+ create_note if res.chomp =~ /^y?$/i
848
+ edit_note
849
+ else
850
+ `#{ENV['EDITOR']} "#{note_file}"`
851
+ end
852
+ end
853
+
854
+ ##
855
+ ## @brief Traverse up directory tree looking for build notes
856
+ ##
857
+ ## @return topics dictionary
858
+ ##
859
+ def glob_upstream
860
+ home = Dir.pwd
861
+ dir = File.dirname(home)
862
+ buildnotes = []
863
+ filename = nil
864
+
865
+ while dir != '/' && (dir =~ %r{[A-Z]:/}).nil?
866
+ Dir.chdir(dir)
867
+ filename = glob_note
868
+ unless filename.nil?
869
+ note = File.join(dir, filename)
870
+ buildnotes.push(note) unless note == note_file
871
+ end
872
+ dir = File.dirname(dir)
873
+ end
874
+
875
+ Dir.chdir(home)
876
+
877
+ buildnotes.reverse
878
+ end
879
+
880
+ def is_build_notes(filename)
881
+ return false if filename.downcase !~ /(^howzit[^.]*|build[^.]+)/
882
+ return false if should_ignore(filename)
883
+ true
884
+ end
885
+
886
+ def should_ignore(filename)
887
+ return false unless File.exist?(ignore_file)
888
+
889
+ unless @ignore_patterns
890
+ @ignore_patterns = YAML.load(IO.read(ignore_file))
891
+ end
892
+
893
+ ignore = false
894
+
895
+ @ignore_patterns.each do |pat|
896
+ if filename =~ /#{pat}/
897
+ ignore = true
898
+ break
899
+ end
900
+ end
901
+
902
+ ignore
903
+ end
904
+
905
+ def glob_note
906
+ filename = nil
907
+ # Check for a build note file in the current folder. Filename must start
908
+ # with "build" and have an extension of txt, md, or markdown.
909
+
910
+ Dir.glob('*.{txt,md,markdown}').each do |f|
911
+ if is_build_notes(f)
912
+ filename = f
913
+ break
914
+ end
915
+ end
916
+ filename
917
+ end
918
+
919
+ def note_file
920
+ @note_file ||= find_note_file
921
+ end
922
+
923
+ def find_note_file
924
+ filename = glob_note
925
+
926
+ if filename.nil? && 'git'.available?
927
+ proj_dir = `git rev-parse --show-toplevel 2>/dev/null`.strip
928
+ unless proj_dir == ''
929
+ Dir.chdir(proj_dir)
930
+ filename = glob_note
931
+ end
932
+ end
933
+
934
+ if filename.nil? && @options[:include_upstream]
935
+ upstream_notes = glob_upstream
936
+ filename = upstream_notes[-1] unless upstream_notes.empty?
937
+ end
938
+
939
+ return nil if filename.nil?
940
+
941
+ File.expand_path(filename)
942
+ end
943
+
944
+ def options_list(matches)
945
+ counter = 1
946
+ puts
947
+ matches.each do |match|
948
+ printf("%<counter>2d ) %<option>s\n", counter: counter, option: match)
949
+ counter += 1
950
+ end
951
+ puts
952
+ end
953
+
954
+ def command_exist?(command)
955
+ exts = ENV.fetch('PATHEXT', '').split(::File::PATH_SEPARATOR)
956
+ if Pathname.new(command).absolute?
957
+ ::File.exist?(command) ||
958
+ exts.any? { |ext| ::File.exist?("#{command}#{ext}") }
959
+ else
960
+ ENV.fetch('PATH', '').split(::File::PATH_SEPARATOR).any? do |dir|
961
+ file = ::File.join(dir, command)
962
+ ::File.exist?(file) ||
963
+ exts.any? { |ext| ::File.exist?("#{file}#{ext}") }
964
+ end
965
+ end
966
+ end
967
+
968
+ def choose(matches)
969
+ if command_exist?('fzf')
970
+ res = `echo #{Shellwords.escape(matches.join("\n"))} | fzf -0 -1 --height #{matches.count + 2} --prompt 'Select a section > '`.strip
971
+ if res.nil? || res.empty?
972
+ warn 'Cancelled'
973
+ Process.exit 0
974
+ end
975
+ return res
976
+ end
977
+
978
+ res = matches[0..9]
979
+ stty_save = `stty -g`.chomp
980
+
981
+ trap('INT') do
982
+ system('stty', stty_save)
983
+ exit
984
+ end
985
+
986
+ options_list(matches)
987
+
988
+ begin
989
+ printf("Type 'q' to cancel, enter for first item", res.length)
990
+ while (line = Readline.readline(': ', true))
991
+ if line =~ /^[a-z]/i
992
+ system('stty', stty_save) # Restore
993
+ exit
994
+ end
995
+ line = line == '' ? 1 : line.to_i
996
+
997
+ return matches[line - 1] if line.positive? && line <= matches.length
998
+
999
+ puts 'Out of range'
1000
+ options_list(matches)
1001
+ end
1002
+ rescue Interrupt
1003
+ system('stty', stty_save)
1004
+ exit
1005
+ end
1006
+ end
1007
+
1008
+ def config_dir
1009
+ File.expand_path(CONFIG_DIR)
1010
+ end
1011
+
1012
+ def config_file
1013
+ File.join(config_dir, CONFIG_FILE)
1014
+ end
1015
+
1016
+ def ignore_file
1017
+ File.join(config_dir, IGNORE_FILE)
1018
+ end
1019
+
1020
+ def template_folder
1021
+ File.join(config_dir, 'templates')
1022
+ end
1023
+
1024
+ def create_config
1025
+ dir, file = [config_dir, config_file]
1026
+ unless File.directory?(dir)
1027
+ warn "Creating config directory at #{dir}"
1028
+ FileUtils.mkdir_p(dir)
1029
+ end
1030
+
1031
+ unless File.exist?(file)
1032
+ warn "Writing fresh config file to #{file}"
1033
+ write_config(defaults)
1034
+ end
1035
+ file
1036
+ end
1037
+
1038
+ def load_config(defaults)
1039
+ file = create_config
1040
+ config = YAML.load(IO.read(file))
1041
+ newconfig = config ? defaults.merge(config) : defaults
1042
+ write_config(newconfig)
1043
+ newconfig
1044
+ end
1045
+
1046
+ def write_config(config)
1047
+ File.open(config_file, 'w') { |f| f.puts config.to_yaml }
1048
+ end
1049
+
1050
+ def edit_config(defaults)
1051
+ raise 'No EDITOR variable defined in environment' if ENV['EDITOR'].nil?
1052
+
1053
+ load_config(defaults)
1054
+ `#{ENV['EDITOR']} "#{config_file}"`
1055
+ end
1056
+
1057
+ def process(args)
1058
+ output = []
1059
+
1060
+ unless note_file
1061
+ Process.exit 0 if @options[:list_runnable_titles] || @options[:list_topic_titles]
1062
+
1063
+ # clear the buffer
1064
+ ARGV.length.times do
1065
+ ARGV.shift
1066
+ end
1067
+ system 'stty cbreak'
1068
+ yn = color_single_options(%w[Y n])
1069
+ $stdout.syswrite "No build notes file found, create one #{yn}? "
1070
+ res = $stdin.sysread 1
1071
+ puts
1072
+ system 'stty cooked'
1073
+
1074
+ create_note if res.chomp =~ /^y?$/i
1075
+ Process.exit 1
1076
+ end
1077
+
1078
+ if @options[:title_only]
1079
+ out = get_note_title(note_file, 20)
1080
+ $stdout.print(out.strip)
1081
+ Process.exit(0)
1082
+ elsif @options[:output_title]
1083
+ title = get_note_title(note_file)
1084
+ if title && !title.empty?
1085
+ header = format_header(title, { hr: "\u{2550}", color: '1;37;40' })
1086
+ output.push("#{header}\n")
1087
+ end
1088
+ end
1089
+
1090
+ if @options[:list_runnable]
1091
+ if @options[:list_runnable_titles]
1092
+ out = list_runnable_titles
1093
+ $stdout.print(out.strip)
1094
+ else
1095
+ out = list_runnable
1096
+ show(out, { color: @options[:color], paginate: false, highlight: false })
1097
+ end
1098
+ Process.exit(0)
1099
+ end
1100
+
1101
+ if @options[:list_topics]
1102
+ if @options[:list_topic_titles]
1103
+ $stdout.print(list_topic_titles)
1104
+ else
1105
+ out = list_topics
1106
+ show(out, { color: @options[:color], paginate: false, highlight: false })
1107
+ end
1108
+ Process.exit(0)
1109
+ end
1110
+
1111
+ topic_match = nil
1112
+ if @options[:grep]
1113
+ topic_match = choose(grep_topics(@options[:grep]))
1114
+ elsif @options[:choose]
1115
+ topic_match = choose(topics.keys)
1116
+ # If there are arguments use those to search for a matching topic
1117
+ elsif !args.empty?
1118
+
1119
+ search = args.join(' ').strip.downcase
1120
+ matches = match_topic(search)
1121
+
1122
+ if matches.empty?
1123
+ output.push(%(\e[0;31mERROR: No topic match found for \e[1;33m#{search}\e[0m\n))
1124
+ if !@options[:show_all_on_error]
1125
+ show(output.join("\n"), { color: true, highlight: false, paginate: false, wrap: 0 })
1126
+ Process.exit 1
1127
+ end
1128
+ elsif matches.length == 1
1129
+ topic_match = matches[0]
1130
+ else
1131
+ topic_match = choose(matches)
1132
+ end
1133
+ end
1134
+
1135
+ if topic_match
1136
+ # If we found a match
1137
+ output.push(process_topic(topic_match, @options[:run], true))
1138
+ else
1139
+ # If there's no argument or no match found, output all
1140
+ topics.each_key { |k| output.push(process_topic(k, false, false)) }
1141
+ end
1142
+ @options[:paginate] = false if @options[:run]
1143
+ show(output.join("\n").strip, @options)
1144
+ end
1145
+ end
1146
+ end