howzit 1.2.5

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