howzit 1.2.13 → 1.2.16
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +18 -5
- data/bin/howzit +193 -2
- data/lib/howzit/buildnote.rb +543 -0
- data/lib/howzit/buildnotes.rb +115 -37
- data/lib/howzit/colors.rb +3 -3
- data/lib/howzit/config.rb +128 -0
- data/lib/howzit/hash.rb +35 -0
- data/lib/howzit/prompt.rb +84 -11
- data/lib/howzit/stringutils.rb +79 -8
- data/lib/howzit/task.rb +22 -0
- data/lib/howzit/topic.rb +233 -0
- data/lib/howzit/util.rb +149 -0
- data/lib/howzit/version.rb +1 -1
- data/lib/howzit.rb +42 -5
- data/spec/ruby_gem_spec.rb +87 -17
- metadata +8 -2
@@ -0,0 +1,543 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Howzit
|
4
|
+
# BuildNote Class
|
5
|
+
class BuildNote
|
6
|
+
attr_accessor :topics
|
7
|
+
|
8
|
+
attr_reader :metadata, :title
|
9
|
+
|
10
|
+
def initialize(file: nil, args: [])
|
11
|
+
@topics = []
|
12
|
+
@metadata = {}
|
13
|
+
|
14
|
+
read_help(file)
|
15
|
+
end
|
16
|
+
|
17
|
+
def inspect
|
18
|
+
puts "#<Howzit::BuildNote @topics=[#{@topics.count}]>"
|
19
|
+
end
|
20
|
+
|
21
|
+
def run
|
22
|
+
process
|
23
|
+
end
|
24
|
+
|
25
|
+
def edit
|
26
|
+
edit_note
|
27
|
+
end
|
28
|
+
|
29
|
+
def find_topic(term)
|
30
|
+
@topics.filter do |topic|
|
31
|
+
rx = term.to_rx
|
32
|
+
topic.title.downcase =~ rx
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def grep(term)
|
37
|
+
@topics.filter { |topic| topic.grep(term) }
|
38
|
+
end
|
39
|
+
|
40
|
+
# Output a list of topic titles
|
41
|
+
def list
|
42
|
+
output = []
|
43
|
+
output.push(Color.template("{bg}Topics:{x}\n"))
|
44
|
+
@topics.each do |topic|
|
45
|
+
output.push(Color.template("- {bw}#{topic.title}{x}"))
|
46
|
+
end
|
47
|
+
output.join("\n")
|
48
|
+
end
|
49
|
+
|
50
|
+
def list_topics
|
51
|
+
@topics.map { |topic| topic.title }
|
52
|
+
end
|
53
|
+
|
54
|
+
def list_completions
|
55
|
+
list_topics.join("\n")
|
56
|
+
end
|
57
|
+
|
58
|
+
def list_runnable_completions
|
59
|
+
output = []
|
60
|
+
@topics.each do |topic|
|
61
|
+
output.push(topic.title) if topic.tasks.count.positive?
|
62
|
+
end
|
63
|
+
output.join("\n")
|
64
|
+
end
|
65
|
+
|
66
|
+
def list_runnable
|
67
|
+
output = []
|
68
|
+
output.push(Color.template(%({bg}"Runnable" Topics:{x}\n)))
|
69
|
+
@topics.each do |topic|
|
70
|
+
s_out = []
|
71
|
+
|
72
|
+
topic.tasks.each do |task|
|
73
|
+
s_out.push(task.to_list)
|
74
|
+
end
|
75
|
+
|
76
|
+
unless s_out.empty?
|
77
|
+
output.push(Color.template("- {bw}#{topic.title}{x}"))
|
78
|
+
output.push(s_out.join("\n"))
|
79
|
+
end
|
80
|
+
end
|
81
|
+
output.join("\n")
|
82
|
+
end
|
83
|
+
|
84
|
+
def read_file(file)
|
85
|
+
read_help_file(file)
|
86
|
+
end
|
87
|
+
|
88
|
+
# Create a buildnotes skeleton
|
89
|
+
def create_note
|
90
|
+
trap('SIGINT') do
|
91
|
+
warn "\nCanceled"
|
92
|
+
exit!
|
93
|
+
end
|
94
|
+
default = !$stdout.isatty || Howzit.options[:default]
|
95
|
+
# First make sure there isn't already a buildnotes file
|
96
|
+
if note_file
|
97
|
+
fname = Color.template("{by}#{note_file}{bw}")
|
98
|
+
unless default
|
99
|
+
res = Prompt.yn("#{fname} exists and appears to be a build note, continue anyway?", false)
|
100
|
+
unless res
|
101
|
+
puts 'Canceled'
|
102
|
+
Process.exit 0
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
title = File.basename(Dir.pwd)
|
108
|
+
if default
|
109
|
+
input = title
|
110
|
+
else
|
111
|
+
printf Color.template("{bw}Project name {xg}[#{title}]{bw}: {x}")
|
112
|
+
input = $stdin.gets.chomp
|
113
|
+
title = input unless input.empty?
|
114
|
+
end
|
115
|
+
summary = ''
|
116
|
+
unless default
|
117
|
+
printf Color.template('{bw}Project summary: {x}')
|
118
|
+
input = $stdin.gets.chomp
|
119
|
+
summary = input unless input.empty?
|
120
|
+
end
|
121
|
+
|
122
|
+
fname = 'buildnotes.md'
|
123
|
+
unless default
|
124
|
+
printf Color.template("{bw}Build notes filename (must begin with 'howzit' or 'build')\n{xg}[#{fname}]{bw}: {x}")
|
125
|
+
input = $stdin.gets.chomp
|
126
|
+
fname = input unless input.empty?
|
127
|
+
end
|
128
|
+
|
129
|
+
note = <<~EOBUILDNOTES
|
130
|
+
# #{title}
|
131
|
+
|
132
|
+
#{summary}
|
133
|
+
|
134
|
+
## File Structure
|
135
|
+
|
136
|
+
Where are the main editable files? Is there a dist/build folder that should be ignored?
|
137
|
+
|
138
|
+
## Build
|
139
|
+
|
140
|
+
What build system/parameters does this use?
|
141
|
+
|
142
|
+
@run(./build command)
|
143
|
+
|
144
|
+
## Deploy
|
145
|
+
|
146
|
+
What are the procedures/commands to deploy this project?
|
147
|
+
|
148
|
+
## Other
|
149
|
+
|
150
|
+
Version control notes, additional gulp/rake/make/etc tasks...
|
151
|
+
|
152
|
+
EOBUILDNOTES
|
153
|
+
|
154
|
+
if File.exist?(fname) && !default
|
155
|
+
file = Color.template("{by}#{fname}")
|
156
|
+
res = Prompt.yn("Are you absolutely sure you want to overwrite #{file}", false)
|
157
|
+
|
158
|
+
unless res
|
159
|
+
puts 'Canceled'
|
160
|
+
Process.exit 0
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
File.open(fname, 'w') do |f|
|
165
|
+
f.puts note
|
166
|
+
puts Color.template("{by}Build notes for #{title} written to #{fname}")
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def note_file
|
171
|
+
@note_file ||= find_note_file
|
172
|
+
end
|
173
|
+
|
174
|
+
private
|
175
|
+
|
176
|
+
##
|
177
|
+
## Traverse up directory tree looking for build notes
|
178
|
+
##
|
179
|
+
## @return topics dictionary
|
180
|
+
##
|
181
|
+
def glob_upstream
|
182
|
+
home = Dir.pwd
|
183
|
+
dir = File.dirname(home)
|
184
|
+
buildnotes = []
|
185
|
+
filename = nil
|
186
|
+
|
187
|
+
while dir != '/' && (dir =~ %r{[A-Z]:/}).nil?
|
188
|
+
Dir.chdir(dir)
|
189
|
+
filename = glob_note
|
190
|
+
unless filename.nil?
|
191
|
+
note = File.join(dir, filename)
|
192
|
+
buildnotes.push(note) unless note == note_file
|
193
|
+
end
|
194
|
+
dir = File.dirname(dir)
|
195
|
+
end
|
196
|
+
|
197
|
+
Dir.chdir(home)
|
198
|
+
|
199
|
+
buildnotes.reverse
|
200
|
+
end
|
201
|
+
|
202
|
+
def is_build_notes(filename)
|
203
|
+
return false if filename.downcase !~ /(^howzit[^.]*|build[^.]+)/
|
204
|
+
return false if Howzit.config.should_ignore(filename)
|
205
|
+
true
|
206
|
+
end
|
207
|
+
|
208
|
+
def glob_note
|
209
|
+
filename = nil
|
210
|
+
# Check for a build note file in the current folder. Filename must start
|
211
|
+
# with "build" and have an extension of txt, md, or markdown.
|
212
|
+
|
213
|
+
Dir.glob('*.{txt,md,markdown}').each do |f|
|
214
|
+
if is_build_notes(f)
|
215
|
+
filename = f
|
216
|
+
break
|
217
|
+
end
|
218
|
+
end
|
219
|
+
filename
|
220
|
+
end
|
221
|
+
|
222
|
+
def find_note_file
|
223
|
+
filename = glob_note
|
224
|
+
|
225
|
+
if filename.nil? && 'git'.available?
|
226
|
+
proj_dir = `git rev-parse --show-toplevel 2>/dev/null`.strip
|
227
|
+
unless proj_dir == ''
|
228
|
+
Dir.chdir(proj_dir)
|
229
|
+
filename = glob_note
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
if filename.nil? && Howzit.options[:include_upstream]
|
234
|
+
upstream_notes = glob_upstream
|
235
|
+
filename = upstream_notes[-1] unless upstream_notes.empty?
|
236
|
+
end
|
237
|
+
|
238
|
+
return nil if filename.nil?
|
239
|
+
|
240
|
+
File.expand_path(filename)
|
241
|
+
end
|
242
|
+
|
243
|
+
def read_upstream
|
244
|
+
buildnotes = glob_upstream
|
245
|
+
|
246
|
+
topics_dict = []
|
247
|
+
buildnotes.each do |path|
|
248
|
+
topics_dict.concat(read_help_file(path))
|
249
|
+
end
|
250
|
+
topics_dict
|
251
|
+
end
|
252
|
+
|
253
|
+
def ensure_requirements(template)
|
254
|
+
t_leader = IO.read(template).split(/^#/)[0].strip
|
255
|
+
if t_leader.length > 0
|
256
|
+
t_meta = t_leader.get_metadata
|
257
|
+
if t_meta.key?('required')
|
258
|
+
required = t_meta['required'].strip.split(/\s*,\s*/)
|
259
|
+
required.each do |req|
|
260
|
+
unless @metadata.keys.include?(req.downcase)
|
261
|
+
warn Color.template(%({xr}ERROR: Missing required metadata key from template '{bw}#{File.basename(template, '.md')}{xr}'{x}))
|
262
|
+
warn Color.template(%({xr}Please define {by}#{req.downcase}{xr} in build notes{x}))
|
263
|
+
Process.exit 1
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
def get_template_topics(content)
|
271
|
+
leader = content.split(/^#/)[0].strip
|
272
|
+
|
273
|
+
template_topics = []
|
274
|
+
|
275
|
+
if leader.length > 0
|
276
|
+
data = leader.get_metadata
|
277
|
+
@metadata = @metadata.merge(data)
|
278
|
+
|
279
|
+
if data.key?('template')
|
280
|
+
templates = data['template'].strip.split(/\s*,\s*/)
|
281
|
+
templates.each do |t|
|
282
|
+
tasks = nil
|
283
|
+
if t =~ /\[(.*?)\]$/
|
284
|
+
tasks = Regexp.last_match[1].split(/\s*,\s*/).map {|t| t.gsub(/\*/, '.*?')}
|
285
|
+
t = t.sub(/\[.*?\]$/, '').strip
|
286
|
+
end
|
287
|
+
|
288
|
+
t_file = t.sub(/(\.md)?$/, '.md')
|
289
|
+
template = File.join(Howzit.config.template_folder, t_file)
|
290
|
+
if File.exist?(template)
|
291
|
+
ensure_requirements(template)
|
292
|
+
|
293
|
+
t_topics = BuildNote.new(file: template)
|
294
|
+
if tasks
|
295
|
+
tasks.each do |task|
|
296
|
+
t_topics.topics.each do |topic|
|
297
|
+
if topic.title =~ /^(.*?:)?#{task}$/i
|
298
|
+
topic.parent = t
|
299
|
+
template_topics.push(topic)
|
300
|
+
end
|
301
|
+
end
|
302
|
+
end
|
303
|
+
else
|
304
|
+
t_topics.topics.map! do |topic|
|
305
|
+
topic.parent = t
|
306
|
+
topic
|
307
|
+
end
|
308
|
+
|
309
|
+
template_topics.concat(t_topics.topics)
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|
314
|
+
end
|
315
|
+
template_topics
|
316
|
+
end
|
317
|
+
|
318
|
+
def include_file(m)
|
319
|
+
file = File.expand_path(m[1])
|
320
|
+
|
321
|
+
return m[0] unless File.exist?(file)
|
322
|
+
|
323
|
+
content = IO.read(file)
|
324
|
+
home = ENV['HOME']
|
325
|
+
short_path = File.dirname(file.sub(/^#{home}/, '~'))
|
326
|
+
prefix = "#{short_path}/#{File.basename(file)}:"
|
327
|
+
parts = content.split(/^##+/)
|
328
|
+
parts.shift
|
329
|
+
if parts.empty?
|
330
|
+
content
|
331
|
+
else
|
332
|
+
"## #{parts.join('## ')}".gsub(/^(##+ *)(?=\S)/, "\\1#{prefix}")
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
def note_title(truncate = 0)
|
337
|
+
help = IO.read(note_file).strip
|
338
|
+
title = help.match(/(?:^(\S.*?)(?=\n==)|^# ?(.*?)$)/)
|
339
|
+
title = if title
|
340
|
+
title[1].nil? ? title[2] : title[1]
|
341
|
+
else
|
342
|
+
note_file.sub(/(\.\w+)?$/, '')
|
343
|
+
end
|
344
|
+
|
345
|
+
title && truncate.positive? ? title.trunc(truncate) : title
|
346
|
+
end
|
347
|
+
|
348
|
+
# Read in the build notes file and output a hash of "Title" => contents
|
349
|
+
def read_help_file(path = nil)
|
350
|
+
topics = []
|
351
|
+
|
352
|
+
filename = path.nil? ? note_file : path
|
353
|
+
|
354
|
+
help = IO.read(filename)
|
355
|
+
|
356
|
+
@title = note_title
|
357
|
+
|
358
|
+
help.gsub!(/@include\((.*?)\)/) do
|
359
|
+
include_file(Regexp.last_match)
|
360
|
+
end
|
361
|
+
|
362
|
+
template_topics = get_template_topics(help)
|
363
|
+
|
364
|
+
split = help.split(/^##+/)
|
365
|
+
split.slice!(0)
|
366
|
+
split.each do |sect|
|
367
|
+
next if sect.strip.empty?
|
368
|
+
|
369
|
+
lines = sect.split(/\n/)
|
370
|
+
title = lines.slice!(0).strip
|
371
|
+
prefix = ''
|
372
|
+
if path
|
373
|
+
if path =~ /#{Howzit.config.template_folder}/
|
374
|
+
short_path = File.basename(path, '.md')
|
375
|
+
else
|
376
|
+
home = ENV['HOME']
|
377
|
+
short_path = File.dirname(path.sub(/^#{home}/, '~'))
|
378
|
+
prefix = "_from #{short_path}_\n\n"
|
379
|
+
end
|
380
|
+
title = "#{short_path}:#{title}"
|
381
|
+
end
|
382
|
+
topic = Topic.new(title, prefix + lines.join("\n").strip.render_template(@metadata))
|
383
|
+
topics.push(topic)
|
384
|
+
end
|
385
|
+
|
386
|
+
template_topics.each do |topic|
|
387
|
+
topics.push(topic) unless find_topic(topic.title.sub(/^.+:/, '')).count.positive?
|
388
|
+
end
|
389
|
+
|
390
|
+
topics
|
391
|
+
end
|
392
|
+
|
393
|
+
def read_help(path = nil)
|
394
|
+
@topics = read_help_file(path)
|
395
|
+
return unless path.nil? && Howzit.options[:include_upstream]
|
396
|
+
|
397
|
+
upstream_topics = read_upstream
|
398
|
+
|
399
|
+
upstream_topics.each do |topic|
|
400
|
+
@topics.push(topic) unless find_topic(title.sub(/^.+:/, '')).count.positive?
|
401
|
+
end
|
402
|
+
end
|
403
|
+
|
404
|
+
def edit_note
|
405
|
+
editor = Howzit.options.fetch(:editor, ENV['EDITOR'])
|
406
|
+
|
407
|
+
raise 'No editor defined' if editor.nil?
|
408
|
+
|
409
|
+
raise "Invalid editor (#{editor})" unless Util.valid_command?(editor)
|
410
|
+
|
411
|
+
if note_file.nil?
|
412
|
+
res = Prompt.yn('No build notes file found, create one?', true)
|
413
|
+
|
414
|
+
create_note if res
|
415
|
+
edit_note
|
416
|
+
else
|
417
|
+
`#{editor} "#{note_file}"`
|
418
|
+
end
|
419
|
+
end
|
420
|
+
|
421
|
+
def process_topic(topic, run, single = false)
|
422
|
+
new_topic = topic.dup
|
423
|
+
|
424
|
+
# Handle variable replacement
|
425
|
+
|
426
|
+
unless Howzit.arguments.empty?
|
427
|
+
new_topic.content = new_topic.content.gsub(/\$(\d+)/) do |m|
|
428
|
+
idx = m[1].to_i - 1
|
429
|
+
Howzit.arguments.length > idx ? Howzit.arguments[idx] : m
|
430
|
+
end
|
431
|
+
new_topic.content = new_topic.content.gsub(/\$[@*]/, Shellwords.join(Howzit.arguments))
|
432
|
+
end
|
433
|
+
|
434
|
+
output = if run
|
435
|
+
new_topic.run
|
436
|
+
else
|
437
|
+
new_topic.print_out({ single: single })
|
438
|
+
end
|
439
|
+
output.nil? ? '' : output.join("\n")
|
440
|
+
end
|
441
|
+
|
442
|
+
def process
|
443
|
+
output = []
|
444
|
+
|
445
|
+
unless note_file
|
446
|
+
Process.exit 0 if Howzit.options[:list_runnable_titles] || Howzit.options[:list_topic_titles]
|
447
|
+
|
448
|
+
# clear the buffer
|
449
|
+
ARGV.length.times do
|
450
|
+
ARGV.shift
|
451
|
+
end
|
452
|
+
res = yn("No build notes file found, create one?", true)
|
453
|
+
create_note if res
|
454
|
+
Process.exit 1
|
455
|
+
end
|
456
|
+
|
457
|
+
if Howzit.options[:title_only]
|
458
|
+
out = note_title(20)
|
459
|
+
$stdout.print(out.strip)
|
460
|
+
Process.exit(0)
|
461
|
+
elsif Howzit.options[:output_title] && !Howzit.options[:run]
|
462
|
+
if @title && !@title.empty?
|
463
|
+
header = @title.format_header({ hr: "\u{2550}", color: '{bwK}' })
|
464
|
+
output.push("#{header}\n")
|
465
|
+
end
|
466
|
+
end
|
467
|
+
|
468
|
+
if Howzit.options[:list_runnable]
|
469
|
+
if Howzit.options[:list_runnable_titles]
|
470
|
+
out = list_runnable_completions
|
471
|
+
$stdout.print(out.strip)
|
472
|
+
else
|
473
|
+
out = list_runnable
|
474
|
+
Util.show(out, { color: Howzit.options[:color], paginate: false, highlight: false })
|
475
|
+
end
|
476
|
+
Process.exit(0)
|
477
|
+
end
|
478
|
+
|
479
|
+
if Howzit.options[:list_topics]
|
480
|
+
if Howzit.options[:list_topic_titles]
|
481
|
+
$stdout.print(list_completions)
|
482
|
+
else
|
483
|
+
out = list
|
484
|
+
Util.show(out, { color: Howzit.options[:color], paginate: false, highlight: false })
|
485
|
+
end
|
486
|
+
Process.exit(0)
|
487
|
+
end
|
488
|
+
|
489
|
+
topic_matches = []
|
490
|
+
if Howzit.options[:grep]
|
491
|
+
matches = grep_topics(Howzit.options[:grep])
|
492
|
+
case Howzit.options[:multiple_matches]
|
493
|
+
when :all
|
494
|
+
topic_matches.concat(matches.sort)
|
495
|
+
else
|
496
|
+
topic_matches.concat(Prompt.choose(matches))
|
497
|
+
end
|
498
|
+
elsif Howzit.options[:choose]
|
499
|
+
titles = Prompt.choose(list_topics)
|
500
|
+
titles.each { |title| topic_matches.push(find_topic(title)[0]) }
|
501
|
+
# If there are arguments use those to search for a matching topic
|
502
|
+
elsif !Howzit.cli_args.empty?
|
503
|
+
search = Howzit.cli_args.join(' ').strip.downcase.split(/ *, */).map(&:strip)
|
504
|
+
|
505
|
+
search.each do |s|
|
506
|
+
matches = find_topic(s)
|
507
|
+
|
508
|
+
if matches.empty?
|
509
|
+
output.push(Color.template(%({bR}ERROR:{xr} No topic match found for {bw}#{s}{x}\n)))
|
510
|
+
else
|
511
|
+
case Howzit.options[:multiple_matches]
|
512
|
+
when :first
|
513
|
+
topic_matches.push(matches[0])
|
514
|
+
when :best
|
515
|
+
topic_matches.push(matches.sort.min_by { |t| t.title.length })
|
516
|
+
when :all
|
517
|
+
topic_matches.concat(matches)
|
518
|
+
else
|
519
|
+
titles = matches.map { |topic| topic.title }
|
520
|
+
res = Prompt.choose(titles)
|
521
|
+
res.each { |title| topic_matches.concat(find_topic(title)) }
|
522
|
+
end
|
523
|
+
end
|
524
|
+
end
|
525
|
+
|
526
|
+
if topic_matches.empty? && !Howzit.options[:show_all_on_error]
|
527
|
+
Util.show(output.join("\n"), { color: true, highlight: false, paginate: false, wrap: 0 })
|
528
|
+
Process.exit 1
|
529
|
+
end
|
530
|
+
end
|
531
|
+
|
532
|
+
if !topic_matches.empty?
|
533
|
+
# If we found a match
|
534
|
+
topic_matches.each { |topic_match| output.push(process_topic(topic_match, Howzit.options[:run], true)) }
|
535
|
+
else
|
536
|
+
# If there's no argument or no match found, output all
|
537
|
+
topics.each { |k| output.push(process_topic(k, false, false)) }
|
538
|
+
end
|
539
|
+
Howzit.options[:paginate] = false if Howzit.options[:run]
|
540
|
+
Util.show(output.join("\n").strip, Howzit.options)
|
541
|
+
end
|
542
|
+
end
|
543
|
+
end
|