howzit 2.1.40 → 2.1.41

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8f040ba3e136aeb10470c40de3cc8272209d176841686aa242dffee1b9fb33f
4
- data.tar.gz: a0e263004a627de3104cb52bb47a04aecacb7eab2389b34819d2ece891331c42
3
+ metadata.gz: 30837683e82b813fd1fd8ff3e2df3177cbee3c735fc5afb2cb3394f08fb341eb
4
+ data.tar.gz: 4a0113424a3e36169a0f7a66f31b6dcc3adc24785d9a1a54829db7d9d907c585
5
5
  SHA512:
6
- metadata.gz: 3d1ce1e58eb28518b0c977f5012436909100cc48fa56dfbf1cedf01389fb02a1ef32823d99b74f065456c07d105f54a1a9dc236a6c73050251ec6180f935af0b
7
- data.tar.gz: e9197120a0dd16ca9aefae85f4dde9f09f06efe56d034faf65eee180aeb5fad02fbe158d8a59b17b007622acfaa44da9aa82312159bc488d49c2f61e5a57568c
6
+ metadata.gz: 1c0245bdb6e801d1efb96e800c04a7ac1177a589e9d25753322bb70a6690ce22b738fdfdc12956aae216565e93ea23b7cdcc14b6f278937e329b116b8d40b5cd
7
+ data.tar.gz: 986c7c270fc4d8aa21c395fab7a1edaa5669c4a72e6160dede78e45c9c9b6065e69ca47c6c4253b1eae1ec2909d4e31224dee333ba74e6b392e937fe1f7f25ae
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ### 2.1.41
2
+
3
+ 2026-05-10 08:18
4
+
5
+ #### NEW
6
+
7
+ - `-z name` and `-n name` in `@if`/`@unless` conditions for shell-style empty and non-empty checks on named and positional variables.
8
+
9
+ #### IMPROVED
10
+
11
+ - `Util.show` strips safely when the default external encoding rejects multibyte content (fewer crashes under strict ASCII locale when piping or highlighting).
12
+
13
+ #### FIXED
14
+
15
+ - @set_var and @log_level inside @if/@else/@elsif so only the active branch runs; the previous behavior ran every branch and could overwrite variables set in another branch (for example ONLY_RUN set in @if then replaced by @else).
16
+ - Topic title parameters from arguments after `--` so they no longer shift when another topic is parsed first with `@include(...[args])` (CLI positional snapshot kept separate from mutable Howzit.arguments during load).
17
+
1
18
  ### 2.1.40
2
19
 
3
20
  2026-02-04 07:53
data/bin/howzit CHANGED
@@ -9,6 +9,8 @@ Howzit::Color.coloring = $stdout.isatty
9
9
  parts = Shellwords.shelljoin(ARGV).split(/ -- /)
10
10
  args = parts[0] ? Shellwords.shellsplit(parts[0]) : []
11
11
  Howzit.arguments = parts[1] ? Shellwords.shellsplit(parts[1]) : []
12
+ # Immutable snapshot for topic title (param): binding — gather_tasks/@include mutates Howzit.arguments while parsing earlier topics
13
+ Howzit.cli_topic_positional_args = Howzit.arguments&.dup || []
12
14
  Howzit.named_arguments = {}
13
15
 
14
16
  OptionParser.new do |opts|
@@ -35,6 +35,17 @@ module Howzit
35
35
  ## Evaluate a single condition (without negation)
36
36
  ##
37
37
  def evaluate_condition(condition, context)
38
+ # Shell-style empty / non-empty tests (bash -z / -n)
39
+ if (match = condition.match(/^-z\s+(.+)$/i))
40
+ name = match[1].strip
41
+ val = get_value(name, context)
42
+ return val.nil? || val.to_s.strip.empty?
43
+ elsif (match = condition.match(/^-n\s+(.+)$/i))
44
+ name = match[1].strip
45
+ val = get_value(name, context)
46
+ return !val.nil? && !val.to_s.strip.empty?
47
+ end
48
+
38
49
  # Handle special conditions FIRST to avoid false matches with comparison patterns
39
50
  # Check file contents before other patterns since it has arguments and operators
40
51
  if condition =~ /^file\s+contents\s+(.+?)\s+(\*\*=|\*=|\^=|\$=|==|!=|=~)\s*(.+)$/i
data/lib/howzit/topic.rb CHANGED
@@ -25,7 +25,7 @@ module Howzit
25
25
  @named_args = {}
26
26
  @metadata = metadata
27
27
  @source_file = source_file
28
- arguments
28
+ arguments(from_cli_snapshot: true)
29
29
 
30
30
  @directives = parse_directives_with_conditionals
31
31
  @tasks = gather_tasks
@@ -33,10 +33,23 @@ module Howzit
33
33
  end
34
34
 
35
35
  # Get named arguments from title
36
- def arguments
36
+ # from_cli_snapshot: use Howzit.cli_topic_positional_args (set once from argv after ` -- `) so earlier topics' gather_tasks
37
+ # cannot clobber positional binding. Re-entrant calls (e.g. @include with [a,b]) pass false to use live Howzit.arguments.
38
+ def arguments(from_cli_snapshot: false)
37
39
  @arg_definitions = []
38
40
  return unless @title =~ /\(.*?\) *$/
39
41
 
42
+ positional = if from_cli_snapshot
43
+ # Specs / non-CLI: leave unset to keep using Howzit.arguments
44
+ if Howzit.cli_topic_positional_args.nil?
45
+ Howzit.arguments || []
46
+ else
47
+ Howzit.cli_topic_positional_args
48
+ end
49
+ else
50
+ Howzit.arguments || []
51
+ end
52
+
40
53
  a = @title.match(/\((?<args>.*?)\) *$/)
41
54
  args = a['args'].split(/ *, */).each(&:strip)
42
55
 
@@ -45,8 +58,8 @@ module Howzit
45
58
  # Store original definition for display purposes
46
59
  @arg_definitions << (default ? "#{arg_name}:#{default}" : arg_name)
47
60
 
48
- @named_args[arg_name] = if Howzit.arguments && Howzit.arguments.count >= idx + 1
49
- Howzit.arguments[idx]
61
+ @named_args[arg_name] = if positional && positional.count >= idx + 1
62
+ positional[idx]
50
63
  else
51
64
  default
52
65
  end
@@ -642,6 +655,7 @@ module Howzit
642
655
  if line =~ /^@log_level\s*\(([^)]+)\)\s*$/i
643
656
  log_level = Regexp.last_match(1).strip
644
657
  conditional_path = conditional_stack.dup
658
+ conditional_path << current_branch_index if current_branch_index
645
659
  directives << Howzit::Directive.new(
646
660
  type: :log_level,
647
661
  log_level_value: log_level,
@@ -664,6 +678,7 @@ module Howzit
664
678
  # Remove quotes from value if present (handles both single and double quotes)
665
679
  var_value = Regexp.last_match(1) if var_value =~ /^["'](.+)["']$/
666
680
  conditional_path = conditional_stack.dup
681
+ conditional_path << current_branch_index if current_branch_index
667
682
  directives << Howzit::Directive.new(
668
683
  type: :set_var,
669
684
  var_name: var_name,
@@ -818,12 +833,16 @@ module Howzit
818
833
 
819
834
  # Handle @log_level directive (before task check)
820
835
  if directive.log_level?
836
+ next unless directive_in_active_branch?(directive, conditional_state)
837
+
821
838
  current_log_level = directive.log_level_value
822
839
  next
823
840
  end
824
841
 
825
842
  # Handle @set_var directive (before task check)
826
843
  if directive.set_var?
844
+ next unless directive_in_active_branch?(directive, conditional_state)
845
+
827
846
  # Set the variable in named_arguments
828
847
  Howzit.named_arguments ||= {}
829
848
  value = directive.var_value
@@ -856,34 +875,7 @@ module Howzit
856
875
  # Handle task directives
857
876
  next unless directive.task?
858
877
 
859
- # Check if all parent conditionals are true
860
- should_execute = true
861
-
862
- # If path ends with an @elsif/@else, skip the parent @if index
863
- # (the index right before the elsif/else in the path)
864
- path_to_check = directive.conditional_path.dup
865
- if path_to_check.length >= 2
866
- last_idx = path_to_check.last
867
- last_state = conditional_state[last_idx]
868
- if last_state && %w[elsif else].include?(last_state[:directive_type])
869
- # Skip the parent @if index (the one before the elsif/else)
870
- parent_if_idx = path_to_check[path_to_check.length - 2]
871
- parent_if_state = conditional_state[parent_if_idx]
872
- if parent_if_state && %w[if unless].include?(parent_if_state[:directive_type])
873
- path_to_check.delete(parent_if_idx)
874
- end
875
- end
876
- end
877
-
878
- path_to_check.each do |cond_idx|
879
- cond_state = conditional_state[cond_idx]
880
- if cond_state.nil? || !cond_state[:evaluated] || !cond_state[:result]
881
- should_execute = false
882
- break
883
- end
884
- end
885
-
886
- next unless should_execute
878
+ next unless directive_in_active_branch?(directive, conditional_state)
887
879
 
888
880
  # Convert directive to task
889
881
  task = directive.to_task(self, current_log_level: current_log_level)
@@ -939,6 +931,38 @@ module Howzit
939
931
  output
940
932
  end
941
933
 
934
+ ##
935
+ ## Whether a directive nested under @if/@unless/@elsif/@else should run (same rules as tasks).
936
+ ##
937
+ def directive_in_active_branch?(directive, conditional_state)
938
+ path = directive.conditional_path || []
939
+ return true if path.empty?
940
+
941
+ should_execute = true
942
+ path_to_check = path.dup
943
+ if path_to_check.length >= 2
944
+ last_idx = path_to_check.last
945
+ last_state = conditional_state[last_idx]
946
+ if last_state && %w[elsif else].include?(last_state[:directive_type])
947
+ parent_if_idx = path_to_check[path_to_check.length - 2]
948
+ parent_if_state = conditional_state[parent_if_idx]
949
+ if parent_if_state && %w[if unless].include?(parent_if_state[:directive_type])
950
+ path_to_check.delete(parent_if_idx)
951
+ end
952
+ end
953
+ end
954
+
955
+ path_to_check.each do |cond_idx|
956
+ cond_state = conditional_state[cond_idx]
957
+ if cond_state.nil? || !cond_state[:evaluated] || !cond_state[:result]
958
+ should_execute = false
959
+ break
960
+ end
961
+ end
962
+
963
+ should_execute
964
+ end
965
+
942
966
  ##
943
967
  ## Find the index of the matching @if/@unless for an @elsif/@else/@end
944
968
  ##
data/lib/howzit/util.rb CHANGED
@@ -161,6 +161,14 @@ module Howzit
161
161
  status.success?
162
162
  end
163
163
 
164
+ # Strip safely when external encoding rejects multibyte content (e.g. US-ASCII default).
165
+ def safe_strip(str)
166
+ s = str.to_s
167
+ s.strip
168
+ rescue Encoding::CompatibilityError
169
+ s.encode('UTF-8', invalid: :replace, undef: :replace).strip
170
+ end
171
+
164
172
  # print output to terminal
165
173
  def show(string, opts = {})
166
174
  options = {
@@ -180,7 +188,10 @@ module Howzit
180
188
  pipes = "|#{hl}" if hl
181
189
  end
182
190
 
183
- output = `echo #{Shellwords.escape(string.strip)}#{pipes}`.strip
191
+ string = safe_strip(string)
192
+
193
+ raw_output = `echo #{Shellwords.escape(string)}#{pipes}`
194
+ output = safe_strip(raw_output)
184
195
 
185
196
  if options[:paginate] && Howzit.options[:paginate]
186
197
  page(output)
@@ -3,5 +3,5 @@
3
3
  # Primary module for this gem.
4
4
  module Howzit
5
5
  # Current Howzit version.
6
- VERSION = '2.1.40'
6
+ VERSION = '2.1.41'
7
7
  end
data/lib/howzit.rb CHANGED
@@ -60,7 +60,7 @@ require 'tty/box'
60
60
  # Main module for howzit
61
61
  module Howzit
62
62
  class << self
63
- attr_accessor :arguments, :named_arguments, :cli_args, :run_log, :multi_topic_run
63
+ attr_accessor :arguments, :named_arguments, :cli_topic_positional_args, :cli_args, :run_log, :multi_topic_run
64
64
 
65
65
  ##
66
66
  ## Holds a Configuration object with methods and a @settings hash
@@ -122,6 +122,30 @@ describe Howzit::ConditionEvaluator do
122
122
  expect(described_class.evaluate('${env} == "production"', {})).to be true
123
123
  expect(described_class.evaluate('${var} == "other"', {})).to be false
124
124
  end
125
+
126
+ it 'evaluates -z (empty) for named arguments' do
127
+ Howzit.named_arguments = { 'option' => '', other: 'x' }
128
+ expect(described_class.evaluate('-z option', {})).to be true
129
+ expect(described_class.evaluate('-z other', {})).to be false
130
+ end
131
+
132
+ it 'evaluates -n (non-empty) for named arguments' do
133
+ Howzit.named_arguments = { 'option' => '', other: 'x' }
134
+ expect(described_class.evaluate('-n option', {})).to be false
135
+ expect(described_class.evaluate('-n other', {})).to be true
136
+ end
137
+
138
+ it 'treats undefined named argument as empty for -z / -n' do
139
+ Howzit.named_arguments = {}
140
+ expect(described_class.evaluate('-z missing', {})).to be true
141
+ expect(described_class.evaluate('-n missing', {})).to be false
142
+ end
143
+
144
+ it 'supports not -z and not -n' do
145
+ Howzit.named_arguments = { opt: 'yes' }
146
+ expect(described_class.evaluate('not -z opt', {})).to be true
147
+ expect(described_class.evaluate('not -n opt', {})).to be false
148
+ end
125
149
  end
126
150
 
127
151
  context 'with metadata' do
@@ -317,4 +317,53 @@ describe 'Sequential Conditional Evaluation' do
317
317
  expect(Howzit.named_arguments['ALL']).to eq('123')
318
318
  end
319
319
  end
320
+
321
+ describe '@set_var under @if/@else' do
322
+ let(:branch_note) do
323
+ <<~EONOTE
324
+ # Test
325
+
326
+ ## Branch Topic (flag)
327
+
328
+ @if -n flag
329
+ @set_var(RESULT, "yes")
330
+ @else
331
+ @set_var(RESULT, "no")
332
+ @end
333
+
334
+ ```run Echo
335
+ #!/bin/bash
336
+ echo ok
337
+ ```
338
+ EONOTE
339
+ end
340
+
341
+ it 'only runs @set_var from the active branch when @if is true' do
342
+ File.open('builda.md', 'w') { |f| f.puts branch_note }
343
+ Howzit.arguments = ['present']
344
+ Howzit.cli_topic_positional_args = ['present']
345
+ Howzit.named_arguments = {}
346
+ Howzit.instance_variable_set(:@buildnote, nil)
347
+ topic = Howzit.buildnote('builda.md').find_topic('Branch Topic')[0]
348
+ allow(Howzit::Prompt).to receive(:yn).and_return(true)
349
+
350
+ topic.run
351
+
352
+ expect(Howzit.named_arguments['RESULT']).to eq('yes')
353
+ end
354
+
355
+ it 'only runs @set_var from the active branch when @else is taken' do
356
+ File.open('builda.md', 'w') { |f| f.puts branch_note }
357
+ Howzit.arguments = []
358
+ Howzit.cli_topic_positional_args = []
359
+ Howzit.named_arguments = {}
360
+ Howzit.instance_variable_set(:@buildnote, nil)
361
+ topic = Howzit.buildnote('builda.md').find_topic('Branch Topic')[0]
362
+ allow(Howzit::Prompt).to receive(:yn).and_return(true)
363
+
364
+ topic.run
365
+
366
+ expect(Howzit.named_arguments['RESULT']).to eq('no')
367
+ end
368
+ end
320
369
  end
data/spec/spec_helper.rb CHANGED
@@ -18,11 +18,19 @@ RSpec.configure do |c|
18
18
  save_buildnote
19
19
  # Reset buildnote cache to ensure fresh instance with updated file
20
20
  Howzit.instance_variable_set(:@buildnote, nil)
21
+ Howzit.arguments = []
22
+ Howzit.cli_topic_positional_args = nil
23
+ Howzit.named_arguments = {}
21
24
  Howzit.options[:include_upstream] = false
22
25
  Howzit.options[:stack] = false
23
26
  Howzit.options[:default] = true
24
27
  Howzit.options[:matching] = 'partial'
25
28
  Howzit.options[:multiple_matches] = 'choose'
29
+ # Point singleton at test fixture (repo may also contain buildnotes.md, etc.)
30
+ Howzit.instance_variable_set(
31
+ :@buildnote,
32
+ Howzit::BuildNote.new(file: File.expand_path('builda.md'))
33
+ )
26
34
  @hz = Howzit.buildnote
27
35
  end
28
36
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tempfile'
5
+
6
+ describe 'Topic title positional args vs gather_tasks' do
7
+ it 'uses CLI positional snapshot so a prior topic @include […] does not shift later topic params' do
8
+ Howzit.arguments = ['from_cli']
9
+ Howzit.cli_topic_positional_args = ['from_cli']
10
+
11
+ Tempfile.create(['howzit-pos', '.md']) do |f|
12
+ f.write(<<~MD)
13
+ defined: snap
14
+
15
+ # Note
16
+
17
+ ## First Topic
18
+
19
+ @include(DoesNotNeedToExist[y])
20
+
21
+ ## Widget Topic (only:falafel)
22
+
23
+ ok
24
+ MD
25
+ f.flush
26
+
27
+ note = Howzit::BuildNote.new(file: f.path)
28
+ widget = note.topics.find { |t| t.title == 'Widget Topic' }
29
+ expect(widget).not_to be_nil
30
+ expect(widget.named_args['only']).to eq('from_cli')
31
+ end
32
+ end
33
+ end
data/spec/topic_spec.rb CHANGED
@@ -24,7 +24,7 @@ end
24
24
 
25
25
  describe Howzit::Topic do
26
26
  subject(:topic) do
27
- bn = Howzit.buildnote
27
+ bn = Howzit.buildnote('builda.md')
28
28
  bn.find_topic('Topic Balogna')[0]
29
29
  end
30
30
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: howzit
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.40
4
+ version: 2.1.41
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
@@ -331,6 +331,7 @@ files:
331
331
  - spec/stack_mode_spec.rb
332
332
  - spec/stringutils_spec.rb
333
333
  - spec/task_spec.rb
334
+ - spec/topic_positional_snapshot_spec.rb
334
335
  - spec/topic_spec.rb
335
336
  - spec/util_spec.rb
336
337
  - src/_README.md
@@ -354,7 +355,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
354
355
  - !ruby/object:Gem::Version
355
356
  version: '0'
356
357
  requirements: []
357
- rubygems_version: 4.0.3
358
+ rubygems_version: 3.6.7
358
359
  specification_version: 4
359
360
  summary: Provides a way to access Markdown project notes by topic with query capabilities
360
361
  and the ability to execute the tasks it describes.
@@ -375,5 +376,6 @@ test_files:
375
376
  - spec/stack_mode_spec.rb
376
377
  - spec/stringutils_spec.rb
377
378
  - spec/task_spec.rb
379
+ - spec/topic_positional_snapshot_spec.rb
378
380
  - spec/topic_spec.rb
379
381
  - spec/util_spec.rb