howzit 2.1.28 → 2.1.30

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.
data/lib/howzit/colors.rb CHANGED
@@ -281,11 +281,40 @@ module Howzit
281
281
  end
282
282
  end
283
283
 
284
- colors = { w: white, k: black, g: green, l: blue,
285
- y: yellow, c: cyan, m: magenta, r: red,
286
- W: bgwhite, K: bgblack, G: bggreen, L: bgblue,
287
- Y: bgyellow, C: bgcyan, M: bgmagenta, R: bgred,
288
- d: dark, b: bold, u: underline, i: italic, x: reset }
284
+ # Build colors hash from configured_colors, generating escape codes directly
285
+ color_map = configured_colors.to_h
286
+ colors = if coloring?
287
+ {
288
+ w: "\e[#{color_map[:white]}m",
289
+ k: "\e[#{color_map[:black]}m",
290
+ g: "\e[#{color_map[:green]}m",
291
+ l: "\e[#{color_map[:blue]}m",
292
+ y: "\e[#{color_map[:yellow]}m",
293
+ c: "\e[#{color_map[:cyan]}m",
294
+ m: "\e[#{color_map[:magenta]}m",
295
+ r: "\e[#{color_map[:red]}m",
296
+ W: "\e[#{color_map[:bgwhite]}m",
297
+ K: "\e[#{color_map[:bgblack]}m",
298
+ G: "\e[#{color_map[:bggreen]}m",
299
+ L: "\e[#{color_map[:bgblue]}m",
300
+ Y: "\e[#{color_map[:bgyellow]}m",
301
+ C: "\e[#{color_map[:bgcyan]}m",
302
+ M: "\e[#{color_map[:bgmagenta]}m",
303
+ R: "\e[#{color_map[:bgred]}m",
304
+ d: "\e[#{color_map[:dark]}m",
305
+ b: "\e[#{color_map[:bold]}m",
306
+ u: "\e[#{color_map[:underline]}m",
307
+ i: "\e[#{color_map[:italic]}m",
308
+ x: "\e[#{color_map[:reset]}m"
309
+ }
310
+ else
311
+ # When coloring is disabled, return empty strings
312
+ {
313
+ w: '', k: '', g: '', l: '', y: '', c: '', m: '', r: '',
314
+ W: '', K: '', G: '', L: '', Y: '', C: '', M: '', R: '',
315
+ d: '', b: '', u: '', i: '', x: ''
316
+ }
317
+ end
289
318
 
290
319
  result = fmt.empty? ? input : format(fmt, colors)
291
320
  # Unescape braces and dollar signs that were escaped to prevent color code interpretation
@@ -295,24 +324,24 @@ module Howzit
295
324
 
296
325
  # Dynamically generate methods for each color name. Each
297
326
  # resulting method can be called with a string or a block.
298
- configured_colors.each do |c, v|
327
+ Color.configured_colors.each do |c, v|
299
328
  new_method = <<-EOSCRIPT
300
- # Color string as #{c}
301
- def #{c}(string = nil)
302
- result = ''
303
- result << "\e[#{v}m" if Howzit::Color.coloring?
304
- if block_given?
305
- result << yield
306
- elsif string.respond_to?(:to_str)
307
- result << string.to_str
308
- elsif respond_to?(:to_str)
309
- result << to_str
310
- else
311
- return result #only switch on
329
+ # Color string as #{c}
330
+ def #{c}(string = nil)
331
+ result = ''
332
+ result << "\e[#{v}m" if Howzit::Color.coloring?
333
+ if block_given?
334
+ result << yield
335
+ elsif string.respond_to?(:to_str)
336
+ result << string.to_str
337
+ elsif respond_to?(:to_str)
338
+ result << to_str
339
+ else
340
+ return result #only switch on
341
+ end
342
+ result << "\e[0m" if Howzit::Color.coloring?
343
+ result
312
344
  end
313
- result << "\e[0m" if Howzit::Color.coloring?
314
- result
315
- end
316
345
  EOSCRIPT
317
346
 
318
347
  module_eval(new_method)
@@ -383,6 +412,5 @@ module Howzit
383
412
  def attributes
384
413
  ATTRIBUTE_NAMES
385
414
  end
386
- extend self
387
415
  end
388
416
  end
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+
5
+ module Howzit
6
+ # Condition Evaluator module
7
+ # Handles evaluation of @if/@unless conditions
8
+ module ConditionEvaluator
9
+ class << self
10
+ ##
11
+ ## Evaluate a condition expression
12
+ ##
13
+ ## @param condition [String] The condition to evaluate
14
+ ## @param context [Hash] Context with metadata, arguments, etc.
15
+ ##
16
+ ## @return [Boolean] Result of condition evaluation
17
+ ##
18
+ def evaluate(condition, context = {})
19
+ condition = condition.strip
20
+
21
+ # Handle negation with 'not' or '!'
22
+ negated = false
23
+ if condition =~ /^(not\s+|!)/
24
+ negated = true
25
+ condition = condition.sub(/^(not\s+|!)/, '').strip
26
+ end
27
+
28
+ result = evaluate_condition(condition, context)
29
+ negated ? !result : result
30
+ end
31
+
32
+ private
33
+
34
+ ##
35
+ ## Evaluate a single condition (without negation)
36
+ ##
37
+ def evaluate_condition(condition, context)
38
+ # Handle special conditions FIRST to avoid false matches with comparison patterns
39
+ # Check file contents before other patterns since it has arguments and operators
40
+ if condition =~ /^file\s+contents\s+(.+?)\s+(\*\*=|\*=|\^=|\$=|==|!=|=~)\s*(.+)$/i
41
+ return evaluate_file_contents(condition, context)
42
+ # Check file/dir/topic exists before other patterns since they have arguments
43
+ elsif condition =~ /^(file\s+exists|dir\s+exists|topic\s+exists)\s+(.+)$/i
44
+ return evaluate_special(condition, context)
45
+ elsif condition =~ /^(git\s+dirty|git\s+clean)$/i
46
+ return evaluate_special(condition, context)
47
+ end
48
+
49
+ # Handle =~ regex comparisons separately (before string == to avoid conflicts)
50
+ if (match = condition.match(%r{^(.+?)\s*=~\s*/(.+)/$}))
51
+ left = match[1].strip
52
+ pattern = match[2].strip
53
+
54
+ left_val = get_value(left, context)
55
+ return false if left_val.nil?
56
+
57
+ !!(left_val.to_s =~ /#{pattern}/)
58
+ # Handle comparisons with ==, !=, >, >=, <, <=
59
+ # Determine if numeric or string comparison based on values
60
+ elsif (match = condition.match(/^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/))
61
+ left = match[1].strip
62
+ operator = match[2]
63
+ right = match[3].strip
64
+
65
+ left_val = get_value(left, context)
66
+ # If get_value returned nil, try using the original string as a literal
67
+ left_val = left if left_val.nil? && numeric_value(left)
68
+ right_val = get_value(right, context)
69
+ # If get_value returned nil, try using the original string as a literal
70
+ right_val = right if right_val.nil? && numeric_value(right)
71
+
72
+ # Try to convert to numbers
73
+ left_num = numeric_value(left_val)
74
+ right_num = numeric_value(right_val)
75
+
76
+ # If both are numeric, use numeric comparison
77
+ if left_num && right_num
78
+ case operator
79
+ when '=='
80
+ left_num == right_num
81
+ when '!='
82
+ left_num != right_num
83
+ when '>'
84
+ left_num > right_num
85
+ when '>='
86
+ left_num >= right_num
87
+ when '<'
88
+ left_num < right_num
89
+ when '<='
90
+ left_num <= right_num
91
+ else
92
+ false
93
+ end
94
+ # Otherwise use string comparison for == and !=, or return false for others
95
+ else
96
+ case operator
97
+ when '=='
98
+ # Handle nil comparisons
99
+ left_val.nil? == right_val.nil? && (left_val.nil? || left_val.to_s == right_val.to_s)
100
+ when '!='
101
+ left_val.nil? != right_val.nil? || (!left_val.nil? && !right_val.nil? && left_val.to_s != right_val.to_s)
102
+ else
103
+ # For >, >=, <, <=, return false if not numeric
104
+ false
105
+ end
106
+ end
107
+ # Handle string-only comparisons: **= (fuzzy match), *= (contains), ^= (starts with), $= (ends with)
108
+ # Note: **= must come before *= in the regex to avoid matching *= first
109
+ elsif (match = condition.match(/^(.+?)\s*(\*\*=|\*=|\^=|\$=)\s*(.+)$/))
110
+ left = match[1].strip
111
+ operator = match[2]
112
+ right = match[3].strip
113
+
114
+ left_val = get_value(left, context)
115
+ right_val = get_value(right, context)
116
+ # If right side is nil (variable not found), treat it as a literal string
117
+ right_val = right if right_val.nil?
118
+
119
+ return false if left_val.nil? || right_val.nil?
120
+
121
+ case operator
122
+ when '*='
123
+ left_val.to_s.include?(right_val.to_s)
124
+ when '^='
125
+ left_val.to_s.start_with?(right_val.to_s)
126
+ when '$='
127
+ left_val.to_s.end_with?(right_val.to_s)
128
+ when '**='
129
+ # Fuzzy match: split search string into chars and join with .*? for regex
130
+ pattern = "^.*?#{right_val.to_s.split('').map { |c| Regexp.escape(c) }.join('.*?')}.*?$"
131
+ !!(left_val.to_s =~ /#{pattern}/)
132
+ else
133
+ false
134
+ end
135
+ # Simple existence check (just a variable name)
136
+ else
137
+ val = get_value(condition, context)
138
+ !val.nil? && val.to_s != ''
139
+ end
140
+ end
141
+
142
+ ##
143
+ ## Get value from various sources
144
+ ##
145
+ def get_value(expr, context)
146
+ expr = expr.strip
147
+
148
+ # Remove quotes if present
149
+ return Regexp.last_match(1) if expr =~ /^["'](.+)["']$/
150
+
151
+ # Remove ${} wrapper if present (for consistency with variable substitution syntax)
152
+ expr = Regexp.last_match(1) if expr =~ /^\$\{(.+)\}$/
153
+
154
+ # Check positional arguments
155
+ if expr =~ /^\$(\d+)$/
156
+ idx = Regexp.last_match(1).to_i - 1
157
+ return Howzit.arguments[idx] if Howzit.arguments && Howzit.arguments[idx]
158
+ end
159
+
160
+ # Check named arguments
161
+ return Howzit.named_arguments[expr.to_sym] if Howzit.named_arguments&.key?(expr.to_sym)
162
+ return Howzit.named_arguments[expr] if Howzit.named_arguments&.key?(expr)
163
+
164
+ # Check metadata (from context only, to avoid circular dependencies)
165
+ metadata = context[:metadata]
166
+ return metadata[expr] if metadata&.key?(expr)
167
+ return metadata[expr.downcase] if metadata&.key?(expr.downcase)
168
+
169
+ # Check environment variables
170
+ return ENV[expr] if ENV.key?(expr)
171
+ return ENV[expr.upcase] if ENV.key?(expr.upcase)
172
+
173
+ # Check for special values: cwd, working directory
174
+ return Dir.pwd if expr =~ /^(cwd|working\s+directory)$/i
175
+
176
+ # Return nil if nothing matched (variable is undefined)
177
+ nil
178
+ end
179
+
180
+ ##
181
+ ## Convert value to numeric if possible
182
+ ##
183
+ def numeric_value(val)
184
+ return val if val.is_a?(Numeric)
185
+
186
+ str = val.to_s.strip
187
+ return nil if str.empty?
188
+
189
+ # Try integer first
190
+ return str.to_i if str =~ /^-?\d+$/
191
+
192
+ # Try float
193
+ return str.to_f if str =~ /^-?\d+\.\d+$/
194
+
195
+ nil
196
+ end
197
+
198
+ ##
199
+ ## Evaluate special conditions
200
+ ##
201
+ def evaluate_special(condition, context)
202
+ condition = condition.downcase.strip
203
+
204
+ if condition =~ /^git\s+dirty$/i
205
+ git_dirty?
206
+ elsif condition =~ /^git\s+clean$/i
207
+ !git_dirty?
208
+ elsif (match = condition.match(/^file\s+exists\s+(.+)$/i))
209
+ file = match[1].strip
210
+ # get_value returns nil if not found, so use original path if nil
211
+ file_val = get_value(file, context)
212
+ file_path = file_val.nil? ? file : file_val.to_s
213
+ File.exist?(file_path) && !File.directory?(file_path)
214
+ elsif (match = condition.match(/^dir\s+exists\s+(.+)$/i))
215
+ dir = match[1].strip
216
+ dir_val = get_value(dir, context)
217
+ dir_path = dir_val.nil? ? dir : dir_val.to_s
218
+ File.directory?(dir_path)
219
+ elsif (match = condition.match(/^topic\s+exists\s+(.+)$/i))
220
+ topic_name = match[1].strip
221
+ topic_name = get_value(topic_name, context).to_s
222
+ find_topic(topic_name)
223
+ else
224
+ false
225
+ end
226
+ end
227
+
228
+ ##
229
+ ## Check if git repository is dirty
230
+ ##
231
+ def git_dirty?
232
+ return false unless `which git`.strip != ''
233
+
234
+ Dir.chdir(Dir.pwd) do
235
+ `git diff --quiet 2>/dev/null`
236
+ $CHILD_STATUS.exitstatus != 0
237
+ end
238
+ end
239
+
240
+ ##
241
+ ## Check if topic exists in buildnote
242
+ ##
243
+ def find_topic(topic_name)
244
+ return false unless Howzit.buildnote
245
+
246
+ matches = Howzit.buildnote.find_topic(topic_name)
247
+ !matches.empty?
248
+ end
249
+
250
+ ##
251
+ ## Evaluate file contents condition
252
+ ## Reads file and performs string comparison
253
+ ##
254
+ def evaluate_file_contents(condition, context)
255
+ match = condition.match(/^file\s+contents\s+(.+?)\s+(\*\*=|\*=|\^=|\$=|==|!=|=~)\s*(.+)$/i)
256
+ return false unless match
257
+
258
+ file_path = match[1].strip
259
+ operator = match[2]
260
+ search_value = match[3].strip
261
+
262
+ # Resolve file path (could be a variable)
263
+ file_path_val = get_value(file_path, context)
264
+ file_path = file_path_val.nil? ? file_path : file_path_val.to_s
265
+
266
+ # Resolve search value (could be a variable)
267
+ search_val = get_value(search_value, context)
268
+ search_val = search_val.nil? ? search_value : search_val.to_s
269
+
270
+ # Read file contents
271
+ return false unless File.exist?(file_path) && !File.directory?(file_path)
272
+
273
+ begin
274
+ file_contents = File.read(file_path).strip
275
+ rescue StandardError
276
+ return false
277
+ end
278
+
279
+ # Perform comparison based on operator
280
+ case operator
281
+ when '=='
282
+ file_contents == search_val.to_s
283
+ when '!='
284
+ file_contents != search_val.to_s
285
+ when '*='
286
+ file_contents.include?(search_val.to_s)
287
+ when '^='
288
+ file_contents.start_with?(search_val.to_s)
289
+ when '$='
290
+ file_contents.end_with?(search_val.to_s)
291
+ when '**='
292
+ # Fuzzy match: split search string into chars and join with .*? for regex
293
+ pattern = "^.*?#{search_val.to_s.split('').map { |c| Regexp.escape(c) }.join('.*?')}.*?$"
294
+ !!(file_contents =~ /#{pattern}/)
295
+ when '=~'
296
+ # Regex match - search_value should be a regex pattern
297
+ pattern = search_val.to_s
298
+ # Remove leading/trailing slashes if present
299
+ pattern = pattern[1..-2] if pattern.start_with?('/') && pattern.end_with?('/')
300
+ !!(file_contents =~ /#{pattern}/)
301
+ else
302
+ false
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Howzit
4
+ # Conditional Content processor
5
+ # Handles @if/@unless/@end blocks in topic content
6
+ module ConditionalContent
7
+ class << self
8
+ ##
9
+ ## Process conditional blocks in content
10
+ ##
11
+ ## @param content [String] The content to process
12
+ ## @param context [Hash] Context for condition evaluation
13
+ ##
14
+ ## @return [String] Content with conditional blocks processed
15
+ ##
16
+ def process(content, context = {})
17
+ lines = content.split(/\n/)
18
+ output = []
19
+ condition_stack = []
20
+ # Track if any condition in the current chain has been true
21
+ # This is used for @elsif and @else to know if a previous branch matched
22
+ chain_matched_stack = []
23
+
24
+ lines.each do |line|
25
+ # Check for @if or @unless
26
+ if line =~ /^@(if|unless)\s+(.+)$/i
27
+ directive = Regexp.last_match(1).downcase
28
+ condition = Regexp.last_match(2).strip
29
+
30
+ # Evaluate condition
31
+ result = ConditionEvaluator.evaluate(condition, context)
32
+ # For @unless, negate the result
33
+ result = !result if directive == 'unless'
34
+
35
+ condition_stack << result
36
+ chain_matched_stack << result
37
+
38
+ # Don't include the @if/@unless line itself
39
+ next
40
+ end
41
+
42
+ # Check for @elsif
43
+ if line =~ /^@elsif\s+(.+)$/i
44
+ condition = Regexp.last_match(1).strip
45
+
46
+ # If previous condition in chain was true, this branch is false
47
+ # Otherwise, evaluate the condition
48
+ if !condition_stack.empty? && chain_matched_stack.last
49
+ # Previous branch matched, so this one is false
50
+ condition_stack[-1] = false
51
+ else
52
+ # Previous branch didn't match, evaluate this condition
53
+ result = ConditionEvaluator.evaluate(condition, context)
54
+ condition_stack[-1] = result
55
+ chain_matched_stack[-1] = result if result
56
+ end
57
+
58
+ # Don't include the @elsif line itself
59
+ next
60
+ end
61
+
62
+ # Check for @else
63
+ if line =~ /^@else\s*$/i
64
+ # If any previous condition in chain was true, this branch is false
65
+ # Otherwise, this branch is true
66
+ if !condition_stack.empty? && chain_matched_stack.last
67
+ # Previous branch matched, so else is false
68
+ condition_stack[-1] = false
69
+ else
70
+ # No previous branch matched, so else is true
71
+ condition_stack[-1] = true
72
+ chain_matched_stack[-1] = true
73
+ end
74
+
75
+ # Don't include the @else line itself
76
+ next
77
+ end
78
+
79
+ # Check for @end - only skip if it's closing an @if/@unless/@elsif/@else block
80
+ if (line =~ /^@end\s*$/) && !condition_stack.empty?
81
+ # This @end closes a conditional block, so skip it
82
+ condition_stack.pop
83
+ chain_matched_stack.pop
84
+ next
85
+ end
86
+ # Otherwise, this @end is for @before/@after, so include it
87
+
88
+ # Include the line only if all conditions in stack are true
89
+ output << line if condition_stack.all? { |cond| cond }
90
+ end
91
+
92
+ output.join("\n")
93
+ end
94
+ end
95
+ end
96
+ end
data/lib/howzit/config.rb CHANGED
@@ -129,14 +129,26 @@ module Howzit
129
129
 
130
130
  ## Update editor config
131
131
  def update_editor
132
- puts 'No $EDITOR defined, no value in config'
132
+ begin
133
+ puts 'No $EDITOR defined, no value in config'
134
+ rescue Errno::EPIPE
135
+ # Pipe closed, ignore
136
+ end
133
137
  editor = Prompt.read_editor
134
138
  if editor.nil?
135
- puts 'Cancelled, no editor stored.'
139
+ begin
140
+ puts 'Cancelled, no editor stored.'
141
+ rescue Errno::EPIPE
142
+ # Pipe closed, ignore
143
+ end
136
144
  Process.exit 1
137
145
  end
138
146
  update_config_option({ config_editor: editor, editor: editor })
139
- puts "Default editor set to #{editor}, modify in config file"
147
+ begin
148
+ puts "Default editor set to #{editor}, modify in config file"
149
+ rescue Errno::EPIPE
150
+ # Pipe closed, ignore
151
+ end
140
152
  editor
141
153
  end
142
154
 
@@ -182,6 +194,19 @@ module Howzit
182
194
  config = load_config
183
195
  load_theme
184
196
  @options = flags.merge(config)
197
+
198
+ # Check for HOWZIT_LOG_LEVEL environment variable
199
+ return unless ENV['HOWZIT_LOG_LEVEL']
200
+
201
+ level_str = ENV['HOWZIT_LOG_LEVEL'].downcase
202
+ level_map = {
203
+ 'debug' => 0,
204
+ 'info' => 1,
205
+ 'warn' => 2,
206
+ 'warning' => 2,
207
+ 'error' => 3
208
+ }
209
+ @options[:log_level] = level_map[level_str] || level_str.to_i
185
210
  end
186
211
 
187
212
  ##
@@ -31,6 +31,50 @@ module Howzit
31
31
  @log_level = Howzit.options[:log_level]
32
32
  end
33
33
 
34
+ ##
35
+ ## Get emoji for log level
36
+ ##
37
+ ## @param level [Symbol] The level
38
+ ##
39
+ ## @return [String] Emoji for the level
40
+ ##
41
+ def emoji_for_level(level)
42
+ case level
43
+ when :debug
44
+ '🔍'
45
+ when :info
46
+ 'â„šī¸'
47
+ when :warn
48
+ 'âš ī¸'
49
+ when :error
50
+ '❌'
51
+ else
52
+ ''
53
+ end
54
+ end
55
+
56
+ ##
57
+ ## Get color prefix for log level
58
+ ##
59
+ ## @param level [Symbol] The level
60
+ ##
61
+ ## @return [String] Color template string
62
+ ##
63
+ def color_for_level(level)
64
+ case level
65
+ when :debug
66
+ '{d}'
67
+ when :info
68
+ '{c}'
69
+ when :warn
70
+ '{y}'
71
+ when :error
72
+ '{r}'
73
+ else
74
+ ''
75
+ end
76
+ end
77
+
34
78
  ##
35
79
  ## Write a message to the console based on the urgency
36
80
  ## level and user's log level setting
@@ -39,7 +83,21 @@ module Howzit
39
83
  ## @param level [Symbol] The level
40
84
  ##
41
85
  def write(msg, level = :info)
42
- $stderr.puts msg if LOG_LEVELS[level] >= @log_level
86
+ return unless LOG_LEVELS[level] >= @log_level
87
+
88
+ emoji = emoji_for_level(level)
89
+ color = color_for_level(level)
90
+ formatted_msg = if emoji && color
91
+ "#{emoji} #{color}#{msg}{x}".c
92
+ else
93
+ msg
94
+ end
95
+
96
+ begin
97
+ $stderr.puts formatted_msg
98
+ rescue Errno::EPIPE
99
+ # Pipe closed, ignore
100
+ end
43
101
  end
44
102
 
45
103
  ##
@@ -66,7 +124,21 @@ module Howzit
66
124
  ## @param msg The message
67
125
  ##
68
126
  def warn(msg)
69
- write msg, :warn
127
+ return unless LOG_LEVELS[:warn] >= @log_level
128
+
129
+ emoji = emoji_for_level(:warn)
130
+ color = color_for_level(:warn)
131
+ formatted_msg = if emoji && color
132
+ "#{emoji} #{color}#{msg}{x}".c
133
+ else
134
+ msg
135
+ end
136
+
137
+ begin
138
+ $stderr.puts formatted_msg
139
+ rescue Errno::EPIPE
140
+ # Pipe closed, ignore
141
+ end
70
142
  end
71
143
 
72
144
  ##