howzit 2.0.7 → 2.0.10

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