howzit 2.1.16 → 2.1.21

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.
@@ -70,9 +70,39 @@ module Howzit
70
70
  def find_topic(term = nil)
71
71
  return @topics if term.nil?
72
72
 
73
+ rx = term.to_rx
74
+
75
+ @topics.filter do |topic|
76
+ title = topic.title.downcase.sub(/ *\(.*?\) *$/, '')
77
+ match = title =~ rx
78
+
79
+ if !match && term =~ /[,:]/
80
+ normalized = title.gsub(/\s*([,:])\s*/, '\1')
81
+ match = normalized =~ rx
82
+ end
83
+
84
+ match
85
+ end
86
+ end
87
+
88
+ ##
89
+ ## Find a topic with an exact whole-word match
90
+ ##
91
+ ## @param term [String] The search term
92
+ ##
93
+ ## @return [Array] Array of topics that exactly match the term
94
+ ##
95
+ def find_topic_exact(term = nil)
96
+ return [] if term.nil?
97
+
73
98
  @topics.filter do |topic|
74
- rx = term.to_rx
75
- topic.title.downcase.sub(/ *\(.*?\) *$/, '') =~ rx
99
+ title = topic.title.downcase.sub(/ *\(.*?\) *$/, '').strip
100
+ # Split both the title and search term into words
101
+ title_words = title.split
102
+ search_words = term.split
103
+
104
+ # Check if all search words match the title words exactly (case-insensitive)
105
+ search_words.map(&:downcase) == title_words.map(&:downcase)
76
106
  end
77
107
  end
78
108
 
@@ -115,7 +145,11 @@ module Howzit
115
145
  ## @return [Array] array of topic titles
116
146
  ##
117
147
  def list_topics
118
- @topics.map(&:title)
148
+ @topics.map do |topic|
149
+ title = topic.title
150
+ title += "(#{topic.named_args.keys.join(', ')})" unless topic.named_args.empty?
151
+ title
152
+ end
119
153
  end
120
154
 
121
155
  ##
@@ -137,7 +171,11 @@ module Howzit
137
171
  def list_runnable_completions
138
172
  output = []
139
173
  @topics.each do |topic|
140
- output.push(topic.title) if topic.tasks.count.positive?
174
+ next unless topic.tasks.count.positive?
175
+
176
+ title = topic.title
177
+ title += "(#{topic.named_args.keys.join(', ')})" unless topic.named_args.empty?
178
+ output.push(title)
141
179
  end
142
180
  output.join("\n")
143
181
  end
@@ -159,7 +197,10 @@ module Howzit
159
197
 
160
198
  next if s_out.empty?
161
199
 
162
- output.push("- {bw}#{topic.title}{x}".c)
200
+ title = topic.title
201
+ title += " {dy}({xy}#{topic.named_args.keys.join(', ')}{dy}){x}" unless topic.named_args.empty?
202
+
203
+ output.push("- {g}#{title}{x}".c)
163
204
  output.push(s_out.join("\n"))
164
205
  end
165
206
 
@@ -328,6 +369,70 @@ module Howzit
328
369
 
329
370
  private
330
371
 
372
+ def topic_search_terms_from_cli
373
+ args = Howzit.cli_args || []
374
+ raw = args.join(' ').strip
375
+ return [] if raw.empty?
376
+
377
+ smart_split_topics(raw).map { |term| term.strip.downcase }.reject(&:empty?)
378
+ end
379
+
380
+ def smart_split_topics(raw)
381
+ segments, separators = segments_and_separators_for(raw)
382
+ return segments if separators.empty?
383
+
384
+ combined = []
385
+ current = segments.shift || ''
386
+
387
+ separators.each_with_index do |separator, idx|
388
+ next_segment = segments[idx] || ''
389
+ if keep_separator_with_current?(current, separator, next_segment)
390
+ current = "#{current}#{separator}#{next_segment}"
391
+ else
392
+ combined << current
393
+ current = next_segment
394
+ end
395
+ end
396
+
397
+ combined << current
398
+ combined
399
+ end
400
+
401
+ def segments_and_separators_for(raw)
402
+ segments = []
403
+ separators = []
404
+ current = String.new
405
+
406
+ raw.each_char do |char|
407
+ if char =~ /[,:]/
408
+ segments << current
409
+ separators << char
410
+ current = String.new
411
+ else
412
+ current << char
413
+ end
414
+ end
415
+
416
+ segments << current
417
+ [segments, separators]
418
+ end
419
+
420
+ def keep_separator_with_current?(current, separator, next_segment)
421
+ candidate = "#{current}#{separator}#{next_segment}"
422
+ normalized_candidate = normalize_separator_string(candidate)
423
+ return false if normalized_candidate.empty?
424
+
425
+ @topics.any? do |topic|
426
+ normalize_separator_string(topic.title).start_with?(normalized_candidate)
427
+ end
428
+ end
429
+
430
+ def normalize_separator_string(string)
431
+ return '' if string.nil?
432
+
433
+ string.downcase.gsub(/\s+/, ' ').strip.gsub(/\s*([,:])\s*/, '\1')
434
+ end
435
+
331
436
  ##
332
437
  ## Import the contents of a filename as new topics
333
438
  ##
@@ -718,6 +823,10 @@ module Howzit
718
823
  ##
719
824
  def process
720
825
  output = []
826
+ if Howzit.options[:run]
827
+ Howzit.run_log = []
828
+ Howzit.multi_topic_run = false
829
+ end
721
830
 
722
831
  unless note_file
723
832
  Process.exit 0 if Howzit.options[:list_runnable_titles] || Howzit.options[:list_topic_titles]
@@ -757,62 +866,139 @@ module Howzit
757
866
  Process.exit(0)
758
867
  end
759
868
 
760
- topic_matches = []
761
-
869
+ # Handle grep and choose modes (batch all results)
762
870
  if Howzit.options[:grep]
871
+ topic_matches = []
763
872
  matches = grep(Howzit.options[:grep])
764
873
  case Howzit.options[:multiple_matches]
765
874
  when :all
766
875
  topic_matches.concat(matches.sort_by(&:title))
767
876
  else
768
- topic_matches.concat(Prompt.choose(matches.map(&:title), height: :max))
877
+ topic_matches.concat(Prompt.choose(matches.map(&:title), height: :max, query: Howzit.options[:grep]))
769
878
  end
879
+ process_topic_matches(topic_matches, output)
770
880
  elsif Howzit.options[:choose]
881
+ topic_matches = []
771
882
  titles = Prompt.choose(list_topics, height: :max)
772
883
  titles.each { |title| topic_matches.push(find_topic(title)[0]) }
773
- # If there are arguments use those to search for a matching topic
884
+ process_topic_matches(topic_matches, output)
774
885
  elsif !Howzit.cli_args.empty?
775
- search = Howzit.cli_args.join(' ').strip.downcase.split(/ *, */).map(&:strip)
886
+ # Collect all topic matches first (showing menus as needed)
887
+ search = topic_search_terms_from_cli
888
+ topic_matches = collect_topic_matches(search, output)
889
+ process_topic_matches(topic_matches, output)
890
+ else
891
+ # No arguments - show all topics
892
+ if Howzit.options[:run]
893
+ Howzit.run_log = []
894
+ Howzit.multi_topic_run = topics.length > 1
895
+ end
896
+ topics.each { |k| output.push(process_topic(k, false, single: false)) }
897
+ finalize_output(output)
898
+ end
899
+ end
900
+
901
+ ##
902
+ ## Collect all topic matches from search terms, showing menus as needed
903
+ ## but not displaying/running until all selections are made
904
+ ##
905
+ ## @param search_terms [Array] Array of search term strings
906
+ ## @param output [Array] Output array for error messages
907
+ ##
908
+ ## @return [Array] Array of all matched topics
909
+ ##
910
+ def collect_topic_matches(search_terms, output)
911
+ all_matches = []
776
912
 
777
- search.each do |s|
778
- matches = find_topic(s)
913
+ search_terms.each do |s|
914
+ # First check for exact whole-word matches
915
+ exact_matches = find_topic_exact(s)
779
916
 
780
- if matches.empty?
781
- output.push(%({bR}ERROR:{xr} No topic match found for {bw}#{s}{x}\n).c)
782
- else
783
- case Howzit.options[:multiple_matches]
784
- when :first
785
- topic_matches.push(matches[0])
786
- when :best
787
- topic_matches.push(matches.sort_by { |a| [a.title.comp_distance(s), a.title.length] })
788
- when :all
789
- topic_matches.concat(matches)
790
- else
791
- titles = matches.map(&:title)
792
- res = Prompt.choose(titles)
793
- old_matching = Howzit.options[:matching]
794
- Howzit.options[:matching] = 'exact'
795
- res.each { |title| topic_matches.concat(find_topic(title)) }
796
- Howzit.options[:matching] = old_matching
797
- end
798
- end
799
- end
917
+ topic_matches = if !exact_matches.empty?
918
+ exact_matches
919
+ else
920
+ resolve_fuzzy_matches(s, output)
921
+ end
800
922
 
801
- if topic_matches.empty? && !Howzit.options[:show_all_on_error]
802
- Util.show(output.join("\n"), { color: true, highlight: false, paginate: false, wrap: 0 })
803
- Process.exit 1
923
+ if topic_matches.empty?
924
+ output.push(%({bR}ERROR:{xr} No topic match found for {bw}#{s}{x}\n).c)
925
+ else
926
+ all_matches.concat(topic_matches)
804
927
  end
805
928
  end
806
929
 
930
+ all_matches
931
+ end
932
+
933
+ ##
934
+ ## Resolve fuzzy matches for a search term
935
+ ##
936
+ ## @param search_term [String] The search term
937
+ ## @param output [Array] Output array for errors
938
+ ##
939
+ ## @return [Array] Array of matched topics
940
+ ##
941
+ def resolve_fuzzy_matches(search_term, output)
942
+ matches = find_topic(search_term)
943
+
944
+ return [] if matches.empty?
945
+
946
+ case Howzit.options[:multiple_matches]
947
+ when :first
948
+ [matches[0]]
949
+ when :best
950
+ [matches.sort_by { |a| [a.title.comp_distance(search_term), a.title.length] }.first]
951
+ when :all
952
+ matches
953
+ else
954
+ titles = matches.map(&:title)
955
+ res = Prompt.choose(titles, query: search_term)
956
+ old_matching = Howzit.options[:matching]
957
+ Howzit.options[:matching] = 'exact'
958
+ selected = res.flat_map { |title| find_topic(title) }
959
+ Howzit.options[:matching] = old_matching
960
+ selected
961
+ end
962
+ end
963
+
964
+ ##
965
+ ## Process collected topic matches and display output
966
+ ##
967
+ ## @param topic_matches [Array] Array of matched topics
968
+ ## @param output [Array] Output array
969
+ ##
970
+ def process_topic_matches(topic_matches, output)
971
+ if topic_matches.empty? && !Howzit.options[:show_all_on_error]
972
+ Util.show(output.join("\n"), { color: true, highlight: false, paginate: false, wrap: 0 })
973
+ Process.exit 1
974
+ end
975
+
976
+ if Howzit.options[:run]
977
+ Howzit.run_log = []
978
+ Howzit.multi_topic_run = topic_matches.length > 1
979
+ end
980
+
807
981
  if !topic_matches.empty?
808
- # If we found a match
809
982
  topic_matches.map! { |topic| topic.is_a?(String) ? find_topic(topic)[0] : topic }
810
983
  topic_matches.each { |topic_match| output.push(process_topic(topic_match, Howzit.options[:run], single: true)) }
811
984
  else
812
- # If there's no argument or no match found, output all
813
985
  topics.each { |k| output.push(process_topic(k, false, single: false)) }
814
986
  end
815
- Howzit.options[:paginate] = false if Howzit.options[:run]
987
+
988
+ finalize_output(output)
989
+ end
990
+
991
+ ##
992
+ ## Finalize and display output with run summary if applicable
993
+ ##
994
+ ## @param output [Array] Output array
995
+ ##
996
+ def finalize_output(output)
997
+ if Howzit.options[:run]
998
+ Howzit.options[:paginate] = false
999
+ summary = Howzit::RunReport.format
1000
+ output.push(summary) unless summary.empty?
1001
+ end
816
1002
  Util.show(output.join("\n").strip, Howzit.options)
817
1003
  end
818
1004
  end
data/lib/howzit/prompt.rb CHANGED
@@ -83,21 +83,24 @@ module Howzit
83
83
  ## number of options, anything
84
84
  ## else gets max height for
85
85
  ## terminal)
86
+ ## @param query [String] The search term to display in prompt
86
87
  ##
87
88
  ## @return [Array] the selected results
88
89
  ##
89
- def choose(matches, height: :auto)
90
- return [] if !$stdout.isatty || matches.count.zero?
90
+ def choose(matches, height: :auto, query: nil)
91
+ return [] if matches.count.zero?
92
+ return matches if matches.count == 1
93
+ return [] unless $stdout.isatty
91
94
 
92
95
  if Util.command_exist?('fzf')
93
96
  height = height == :auto ? matches.count + 3 : TTY::Screen.rows
94
97
 
95
- settings = fzf_options(height)
98
+ settings = fzf_options(height, query: query)
96
99
  res = `echo #{Shellwords.escape(matches.join("\n"))} | fzf #{settings.join(' ')}`.strip
97
100
  return fzf_result(res)
98
101
  end
99
102
 
100
- tty_menu(matches)
103
+ tty_menu(matches, query: query)
101
104
  end
102
105
 
103
106
  def fzf_result(res)
@@ -108,7 +111,12 @@ module Howzit
108
111
  res.split(/\n/)
109
112
  end
110
113
 
111
- def fzf_options(height)
114
+ def fzf_options(height, query: nil)
115
+ prompt = if query
116
+ "Select a topic for \\`#{query}\\` > "
117
+ else
118
+ 'Select a topic > '
119
+ end
112
120
  [
113
121
  '-0',
114
122
  '-1',
@@ -116,7 +124,7 @@ module Howzit
116
124
  "--height=#{height}",
117
125
  '--header="Tab: add selection, ctrl-a/d: (de)select all, return: display/run"',
118
126
  '--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all',
119
- '--prompt="Select a topic > "',
127
+ "--prompt=\"#{prompt}\"",
120
128
  %(--preview="howzit --no-pager --header-format block --no-color --default --multiple first {}")
121
129
  ]
122
130
  end
@@ -125,8 +133,9 @@ module Howzit
125
133
  ## Display a numeric menu on the TTY
126
134
  ##
127
135
  ## @param matches The matches from which to select
136
+ ## @param query [String] The search term to display
128
137
  ##
129
- def tty_menu(matches)
138
+ def tty_menu(matches, query: nil)
130
139
  return matches if matches.count == 1
131
140
 
132
141
  @stty_save = `stty -g`.chomp
@@ -136,6 +145,9 @@ module Howzit
136
145
  exit
137
146
  end
138
147
 
148
+ if query
149
+ puts "\nSelect a topic for `#{query}`:"
150
+ end
139
151
  options_list(matches)
140
152
  read_selection(matches)
141
153
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Howzit
4
+ # Formatter for task run summaries
5
+ module RunReport
6
+ module_function
7
+
8
+ def reset
9
+ Howzit.run_log = []
10
+ end
11
+
12
+ def log(entry)
13
+ Howzit.run_log = [] if Howzit.run_log.nil?
14
+ Howzit.run_log << entry
15
+ end
16
+
17
+ def entries
18
+ Howzit.run_log || []
19
+ end
20
+
21
+ def format
22
+ return '' if entries.empty?
23
+
24
+ lines = entries.map { |entry| format_line(entry, Howzit.multi_topic_run) }
25
+ lines.map! { |line| line.rstrip }
26
+ widths = lines.map { |line| line.uncolor.length }
27
+ width = widths.max
28
+ top = '=' * width
29
+ bottom = '-' * width
30
+ output_lines = [top] + lines + [bottom]
31
+ result = output_lines.join("\n")
32
+ result = result.gsub(/\n[ \t]+\n/, "\n")
33
+ result.gsub(/\n{2,}/, "\n")
34
+ end
35
+
36
+ def format_line(entry, prefix_topic)
37
+ bullet_start = '{mb}- [{x}'
38
+ bullet_end = '{mb}] {x}'
39
+ symbol = entry[:success] ? '{bg}✓{x}' : '{br}X{x}'
40
+ parts = []
41
+ parts << "#{bullet_start}#{symbol}#{bullet_end}"
42
+ parts << "{bl}#{entry[:topic]}{x}: " if prefix_topic && entry[:topic] && !entry[:topic].empty?
43
+ parts << "{by}#{entry[:task]}{x}"
44
+ unless entry[:success]
45
+ reason = entry[:exit_status] ? "exit code #{entry[:exit_status]}" : 'failed'
46
+ parts << " {br}(Failed: #{reason}){x}"
47
+ end
48
+ parts.join.c
49
+ end
50
+ end
51
+ end
@@ -183,7 +183,9 @@ module Howzit
183
183
 
184
184
  # Just strip out color codes when requested
185
185
  def uncolor
186
- gsub(/\e\[[\d;]+m/, '').gsub(/\e\]1337;SetMark/, '')
186
+ # force UTF-8 and remove invalid characters, then remove color codes
187
+ # and iTerm markers
188
+ gsub(Howzit::Color::COLORED_REGEXP, "").gsub(/\e\]1337;SetMark/, "")
187
189
  end
188
190
 
189
191
  # Wrap text at a specified width.
data/lib/howzit/task.rb CHANGED
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'English'
4
+
3
5
  module Howzit
4
6
  # Task object
5
7
  class Task
6
- attr_reader :type, :title, :action, :arguments, :parent, :optional, :default
8
+ attr_reader :type, :title, :action, :arguments, :parent, :optional, :default, :last_status
7
9
 
8
10
  ##
9
11
  ## Initialize a Task object
@@ -31,6 +33,7 @@ module Howzit
31
33
 
32
34
  @optional = optional
33
35
  @default = default
36
+ @last_status = nil
34
37
  end
35
38
 
36
39
  ##
@@ -68,6 +71,7 @@ module Howzit
68
71
  script.unlink
69
72
  end
70
73
 
74
+ update_last_status(res ? 0 : 1)
71
75
  res
72
76
  end
73
77
 
@@ -86,6 +90,7 @@ module Howzit
86
90
  Howzit.console.info("#{@prefix}{by}Running tasks from {bw}#{matches[0].title}{x}".c)
87
91
  output.concat(matches[0].run(nested: true))
88
92
  Howzit.console.info("{by}End include: #{matches[0].tasks.count} tasks{x}".c)
93
+ @last_status = nil
89
94
  [output, matches[0].tasks.count]
90
95
  end
91
96
 
@@ -96,7 +101,9 @@ module Howzit
96
101
  title = Howzit.options[:show_all_code] ? @action : @title
97
102
  Howzit.console.info("#{@prefix}{bg}Running {bw}#{title}{x}".c)
98
103
  ENV['HOWZIT_SCRIPTS'] = File.expand_path('~/.config/howzit/scripts')
99
- system(@action)
104
+ res = system(@action)
105
+ update_last_status(res ? 0 : 1)
106
+ res
100
107
  end
101
108
 
102
109
  ##
@@ -106,6 +113,7 @@ module Howzit
106
113
  title = Howzit.options[:show_all_code] ? @action : @title
107
114
  Howzit.console.info("#{@prefix}{bg}Copied {bw}#{title}{bg} to clipboard{x}".c)
108
115
  Util.os_copy(@action)
116
+ @last_status = 0
109
117
  true
110
118
  end
111
119
 
@@ -127,12 +135,23 @@ module Howzit
127
135
  run_copy
128
136
  when :open
129
137
  Util.os_open(@action)
138
+ @last_status = 0
139
+ true
130
140
  end
131
141
  end
132
142
 
133
143
  [output, tasks, res]
134
144
  end
135
145
 
146
+ def update_last_status(default = nil)
147
+ status = if defined?($CHILD_STATUS) && $CHILD_STATUS
148
+ $CHILD_STATUS.exitstatus
149
+ else
150
+ default
151
+ end
152
+ @last_status = status
153
+ end
154
+
136
155
  ##
137
156
  ## Output terminal-formatted list item
138
157
  ##
data/lib/howzit/topic.rb CHANGED
@@ -104,6 +104,8 @@ module Howzit
104
104
 
105
105
  break unless Howzit.options[:force]
106
106
  end
107
+
108
+ log_task_result(task, success)
107
109
  end
108
110
 
109
111
  total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
@@ -119,7 +121,7 @@ module Howzit
119
121
  Howzit.console.warn "{r}--run: No {br}@directive{xr} found in {bw}#{@title}{x}".c
120
122
  end
121
123
 
122
- output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested
124
+ output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]
123
125
 
124
126
  puts TTY::Box.frame("{bw}#{@postreqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols) unless @postreqs.empty?
125
127
 
@@ -324,6 +326,27 @@ module Howzit
324
326
  ##
325
327
  ## @return [Array] array of Task objects
326
328
  ##
329
+ def log_task_result(task, success)
330
+ return unless Howzit.options[:run]
331
+ return if task.type == :include
332
+
333
+ Howzit.run_log ||= []
334
+
335
+ title = (task.title || '').strip
336
+ if title.empty?
337
+ action = (task.action || '').strip
338
+ title = action.split(/\n/).first.to_s.strip
339
+ end
340
+ title = task.type.to_s.capitalize if title.nil? || title.empty?
341
+
342
+ Howzit.run_log << {
343
+ topic: @title,
344
+ task: title,
345
+ success: success ? true : false,
346
+ exit_status: task.last_status
347
+ }
348
+ end
349
+
327
350
  def gather_tasks
328
351
  runnable = []
329
352
  @prereqs = @content.scan(/(?<=@before\n).*?(?=\n@end)/im).map(&:strip)
data/lib/howzit/util.rb CHANGED
@@ -145,7 +145,11 @@ module Howzit
145
145
  end
146
146
 
147
147
  read_io.close
148
- write_io.write(text)
148
+ begin
149
+ write_io.write(text)
150
+ rescue Errno::EPIPE
151
+ # User quit pager before we finished writing, ignore
152
+ end
149
153
  write_io.close
150
154
 
151
155
  _, status = Process.waitpid2(pid)
@@ -3,5 +3,5 @@
3
3
  # Primary module for this gem.
4
4
  module Howzit
5
5
  # Current Howzit version.
6
- VERSION = '2.1.16'
6
+ VERSION = '2.1.21'
7
7
  end
data/lib/howzit.rb CHANGED
@@ -41,6 +41,7 @@ require_relative 'howzit/config'
41
41
  require_relative 'howzit/task'
42
42
  require_relative 'howzit/topic'
43
43
  require_relative 'howzit/buildnote'
44
+ require_relative 'howzit/run_report'
44
45
 
45
46
  require 'tty/screen'
46
47
  require 'tty/box'
@@ -49,7 +50,7 @@ require 'tty/box'
49
50
  # Main module for howzit
50
51
  module Howzit
51
52
  class << self
52
- attr_accessor :arguments, :named_arguments, :cli_args
53
+ attr_accessor :arguments, :named_arguments, :cli_args, :run_log, :multi_topic_run
53
54
 
54
55
  ##
55
56
  ## Holds a Configuration object with methods and a @settings hash
@@ -88,10 +89,18 @@ module Howzit
88
89
  @console ||= Howzit::ConsoleLogger.new(options[:log_level])
89
90
  end
90
91
 
92
+ def run_log
93
+ @run_log ||= []
94
+ end
95
+
96
+ def multi_topic_run
97
+ @multi_topic_run ||= false
98
+ end
99
+
91
100
  def has_read_upstream
92
101
  @has_read_upstream ||= false
93
102
  end
94
103
 
95
- attr_writer :has_read_upstream
104
+ attr_writer :has_read_upstream, :run_log
96
105
  end
97
106
  end