howzit 2.1.40 → 2.1.42
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 +29 -0
- data/bin/howzit +2 -0
- data/lib/howzit/buildnote.rb +122 -15
- data/lib/howzit/condition_evaluator.rb +11 -0
- data/lib/howzit/topic.rb +57 -32
- data/lib/howzit/util.rb +24 -26
- data/lib/howzit/version.rb +1 -1
- data/lib/howzit.rb +1 -1
- data/spec/cli_spec.rb +1 -0
- data/spec/condition_evaluator_spec.rb +24 -0
- data/spec/includes_metadata_spec.rb +124 -0
- data/spec/sequential_conditional_spec.rb +49 -0
- data/spec/spec_helper.rb +8 -1
- data/spec/topic_positional_snapshot_spec.rb +33 -0
- data/spec/topic_spec.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8916b2ac37cd920db8008fe7a9aa28a80b4c5265083b4b257fb8ea1113938efb
|
|
4
|
+
data.tar.gz: 9486d6ca52f82ea5059bffd0de58333b82eedfc623d03f6325817caf8c956d4f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5819786618c45538e42f21243102043b1ed408f516b0b946adaa9a2819d0080c2804cba47987f4ba0ed98b21ad7499d42cc825c2f89efb63cc4f6d8013bd743d
|
|
7
|
+
data.tar.gz: 2a90edf5e41602e1c6fc50ab21a6bfc3531b598f3a5cc381492f284bbb588b2590589fbc23da0f007a021605d46f7246e2908eed89895c175c24838b4755b285
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
### 2.1.42
|
|
2
|
+
|
|
3
|
+
2026-06-29 06:52
|
|
4
|
+
|
|
5
|
+
#### NEW
|
|
6
|
+
|
|
7
|
+
- `includes:` metadata to pull topics from external build note files or project directories (comma-separated paths, optional `[topic | filter]` like templates); directory paths auto-detect the build note; included topics are prefixed by basename; local topics always win on duplicate names.
|
|
8
|
+
|
|
9
|
+
#### FIXED
|
|
10
|
+
|
|
11
|
+
- Paged build note output no longer opens with the first line scrolled off screen (pager receives the full buffer via IO.popen instead of starting on partial pipe input).
|
|
12
|
+
|
|
13
|
+
### 2.1.41
|
|
14
|
+
|
|
15
|
+
2026-05-10 08:18
|
|
16
|
+
|
|
17
|
+
#### NEW
|
|
18
|
+
|
|
19
|
+
- `-z name` and `-n name` in `@if`/`@unless` conditions for shell-style empty and non-empty checks on named and positional variables.
|
|
20
|
+
|
|
21
|
+
#### IMPROVED
|
|
22
|
+
|
|
23
|
+
- `Util.show` strips safely when the default external encoding rejects multibyte content (fewer crashes under strict ASCII locale when piping or highlighting).
|
|
24
|
+
|
|
25
|
+
#### FIXED
|
|
26
|
+
|
|
27
|
+
- @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).
|
|
28
|
+
- 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).
|
|
29
|
+
|
|
1
30
|
### 2.1.40
|
|
2
31
|
|
|
3
32
|
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|
|
data/lib/howzit/buildnote.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
3
5
|
module Howzit
|
|
4
6
|
# BuildNote Class
|
|
5
7
|
class BuildNote
|
|
@@ -12,9 +14,11 @@ module Howzit
|
|
|
12
14
|
##
|
|
13
15
|
## @param file [String] The path to the build note file
|
|
14
16
|
##
|
|
15
|
-
def initialize(file: nil, meta: nil)
|
|
17
|
+
def initialize(file: nil, meta: nil, skip_includes: false, included_paths: nil)
|
|
16
18
|
# Track if an explicit file was provided
|
|
17
19
|
@explicit_file = file ? File.expand_path(file) : nil
|
|
20
|
+
@skip_includes = skip_includes
|
|
21
|
+
@included_paths = included_paths || Set.new
|
|
18
22
|
|
|
19
23
|
# Set @note_file if an explicit file was provided, before calling note_file getter
|
|
20
24
|
if file
|
|
@@ -653,7 +657,7 @@ module Howzit
|
|
|
653
657
|
## @return [Array] extracted topics
|
|
654
658
|
##
|
|
655
659
|
def read_template(template, file, subtopics = nil)
|
|
656
|
-
note = BuildNote.new(file: file, meta: @metadata)
|
|
660
|
+
note = BuildNote.new(file: file, meta: @metadata, skip_includes: true, included_paths: @included_paths)
|
|
657
661
|
|
|
658
662
|
template_topics = subtopics.nil? ? note.topics : extract_subtopics(note, subtopics)
|
|
659
663
|
template_topics.map do |topic|
|
|
@@ -751,6 +755,24 @@ module Howzit
|
|
|
751
755
|
files.min
|
|
752
756
|
end
|
|
753
757
|
|
|
758
|
+
##
|
|
759
|
+
## Glob a directory for a valid build note filename
|
|
760
|
+
##
|
|
761
|
+
## @param dir [String] Directory to search
|
|
762
|
+
##
|
|
763
|
+
## @return [String, nil] Absolute path to build note file
|
|
764
|
+
##
|
|
765
|
+
def glob_note_in(dir)
|
|
766
|
+
pattern = File.join(File.expand_path(dir), '*.{txt,md,markdown}')
|
|
767
|
+
files = Dir.glob(pattern).select { |f| File.basename(f).build_note? }
|
|
768
|
+
return nil if files.empty?
|
|
769
|
+
|
|
770
|
+
priority_files = files.select { |f| File.basename(f) =~ /^(buildnotes|howzit)\./i }
|
|
771
|
+
return priority_files.min unless priority_files.empty?
|
|
772
|
+
|
|
773
|
+
files.min
|
|
774
|
+
end
|
|
775
|
+
|
|
754
776
|
##
|
|
755
777
|
## Search for a valid build note, checking current
|
|
756
778
|
## directory, git top level directory, and parent
|
|
@@ -794,26 +816,117 @@ module Howzit
|
|
|
794
816
|
end
|
|
795
817
|
|
|
796
818
|
##
|
|
797
|
-
## Read a list of topics from
|
|
819
|
+
## Read a list of topics from included templates and includes: metadata
|
|
798
820
|
##
|
|
799
|
-
## @param content [String] The
|
|
821
|
+
## @param content [String] The build note contents
|
|
800
822
|
##
|
|
801
823
|
def get_template_topics(content)
|
|
802
824
|
leader = content.split(/^#/)[0].strip
|
|
803
825
|
|
|
804
|
-
|
|
826
|
+
external_topics = []
|
|
805
827
|
|
|
806
|
-
return
|
|
828
|
+
return external_topics if leader.empty?
|
|
807
829
|
|
|
808
830
|
data = leader.metadata
|
|
809
831
|
|
|
810
832
|
if data.key?('template')
|
|
811
833
|
templates = data['template'].strip.split(/\s*,\s*/)
|
|
812
834
|
|
|
813
|
-
|
|
835
|
+
external_topics.concat(gather_templates(templates))
|
|
814
836
|
end
|
|
815
837
|
|
|
816
|
-
|
|
838
|
+
if data.key?('includes') && !@skip_includes
|
|
839
|
+
includes = data['includes'].strip.split(/\s*,\s*/)
|
|
840
|
+
|
|
841
|
+
external_topics.concat(gather_includes(includes))
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
external_topics
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
##
|
|
848
|
+
## Resolve an includes: metadata entry to a label and build note file path
|
|
849
|
+
##
|
|
850
|
+
## @param path_spec [String] File path, directory, or ~ path
|
|
851
|
+
##
|
|
852
|
+
## @return [Array, nil] [label, absolute_file_path] or nil if not found
|
|
853
|
+
##
|
|
854
|
+
def resolve_include_path(path_spec)
|
|
855
|
+
path = File.expand_path(path_spec.to_s.strip)
|
|
856
|
+
return nil unless File.exist?(path)
|
|
857
|
+
|
|
858
|
+
if File.directory?(path)
|
|
859
|
+
note_path = glob_note_in(path)
|
|
860
|
+
return nil unless note_path
|
|
861
|
+
|
|
862
|
+
[File.basename(path), File.expand_path(note_path)]
|
|
863
|
+
elsif File.file?(path)
|
|
864
|
+
[File.basename(path, File.extname(path)), path]
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
##
|
|
869
|
+
## Read topics from an external build note referenced by includes: metadata
|
|
870
|
+
##
|
|
871
|
+
def read_include(label, file, subtopics = nil)
|
|
872
|
+
note = BuildNote.new(file: file, meta: @metadata, skip_includes: true, included_paths: @included_paths)
|
|
873
|
+
included_topics = subtopics.nil? ? note.topics : extract_subtopics(note, subtopics)
|
|
874
|
+
included_topics.map do |topic|
|
|
875
|
+
topic.parent = label
|
|
876
|
+
topic.content = topic.content.render_template(@metadata)
|
|
877
|
+
topic.instance_variable_set(:@title, "#{label}:#{topic.title}") unless topic.title.include?(':')
|
|
878
|
+
topic
|
|
879
|
+
end
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
##
|
|
883
|
+
## Load topics from includes: metadata paths (comma-separated)
|
|
884
|
+
##
|
|
885
|
+
def gather_includes(includes)
|
|
886
|
+
included_topics = []
|
|
887
|
+
|
|
888
|
+
includes.each do |spec|
|
|
889
|
+
spec = spec.strip
|
|
890
|
+
next if spec.empty?
|
|
891
|
+
|
|
892
|
+
path_spec, subtopics = detect_subtopics(spec.dup)
|
|
893
|
+
resolved = resolve_include_path(path_spec)
|
|
894
|
+
unless resolved
|
|
895
|
+
Howzit.console.warn "{br}WARNING:{xr} includes: path not found or has no build note: {bw}#{path_spec}{x}".c
|
|
896
|
+
next
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
label, file = resolved
|
|
900
|
+
abs_file = File.expand_path(file)
|
|
901
|
+
main_file = @note_file ? File.expand_path(@note_file) : nil
|
|
902
|
+
|
|
903
|
+
if main_file && abs_file == main_file
|
|
904
|
+
Howzit.console.warn "{br}WARNING:{xr} includes: skipping self-reference: {bw}#{path_spec}{x}".c
|
|
905
|
+
next
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
if @included_paths.include?(abs_file)
|
|
909
|
+
Howzit.console.warn "{br}WARNING:{xr} includes: already loaded, skipping: {bw}#{path_spec}{x}".c
|
|
910
|
+
next
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
@included_paths.add(abs_file)
|
|
914
|
+
included_topics.concat(read_include(label, abs_file, subtopics))
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
included_topics
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
##
|
|
921
|
+
## Merge external topics (templates, includes) into local topics; local wins on duplicate names
|
|
922
|
+
##
|
|
923
|
+
def merge_external_topics(local_topics, external_topics)
|
|
924
|
+
external_topics.each do |topic|
|
|
925
|
+
base = base_topic_name(topic.title)
|
|
926
|
+
exists_in_local = local_topics.any? { |t| base_topic_name(t.title) == base }
|
|
927
|
+
local_topics.push(topic) unless exists_in_local
|
|
928
|
+
end
|
|
929
|
+
local_topics
|
|
817
930
|
end
|
|
818
931
|
|
|
819
932
|
# Read in the build notes file and output a hash of
|
|
@@ -870,13 +983,7 @@ module Howzit
|
|
|
870
983
|
topics.push(topic)
|
|
871
984
|
end
|
|
872
985
|
|
|
873
|
-
template_topics
|
|
874
|
-
# Check against local topics array, not @topics, to avoid filtering out templates
|
|
875
|
-
# when @topics already has topics (e.g., in stack mode)
|
|
876
|
-
topic_base = topic.title.sub(/^.+:/, '').strip.downcase
|
|
877
|
-
exists_in_local = topics.any? { |t| t.title.sub(/^.+:/, '').strip.downcase == topic_base }
|
|
878
|
-
topics.push(topic) unless exists_in_local
|
|
879
|
-
end
|
|
986
|
+
merge_external_topics(topics, template_topics)
|
|
880
987
|
|
|
881
988
|
topics
|
|
882
989
|
end
|
|
@@ -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,24 @@ module Howzit
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
# Get named arguments from title
|
|
36
|
-
|
|
36
|
+
# from_cli_snapshot: use Howzit.cli_topic_positional_args (argv after `--`) so earlier
|
|
37
|
+
# topics' gather_tasks cannot clobber positional binding. Re-entrant @include [a,b]
|
|
38
|
+
# calls pass false to use live Howzit.arguments.
|
|
39
|
+
def arguments(from_cli_snapshot: false)
|
|
37
40
|
@arg_definitions = []
|
|
38
41
|
return unless @title =~ /\(.*?\) *$/
|
|
39
42
|
|
|
43
|
+
positional = if from_cli_snapshot
|
|
44
|
+
# Specs / non-CLI: leave unset to keep using Howzit.arguments
|
|
45
|
+
if Howzit.cli_topic_positional_args.nil?
|
|
46
|
+
Howzit.arguments || []
|
|
47
|
+
else
|
|
48
|
+
Howzit.cli_topic_positional_args
|
|
49
|
+
end
|
|
50
|
+
else
|
|
51
|
+
Howzit.arguments || []
|
|
52
|
+
end
|
|
53
|
+
|
|
40
54
|
a = @title.match(/\((?<args>.*?)\) *$/)
|
|
41
55
|
args = a['args'].split(/ *, */).each(&:strip)
|
|
42
56
|
|
|
@@ -45,8 +59,8 @@ module Howzit
|
|
|
45
59
|
# Store original definition for display purposes
|
|
46
60
|
@arg_definitions << (default ? "#{arg_name}:#{default}" : arg_name)
|
|
47
61
|
|
|
48
|
-
@named_args[arg_name] = if
|
|
49
|
-
|
|
62
|
+
@named_args[arg_name] = if positional && positional.count >= idx + 1
|
|
63
|
+
positional[idx]
|
|
50
64
|
else
|
|
51
65
|
default
|
|
52
66
|
end
|
|
@@ -642,6 +656,7 @@ module Howzit
|
|
|
642
656
|
if line =~ /^@log_level\s*\(([^)]+)\)\s*$/i
|
|
643
657
|
log_level = Regexp.last_match(1).strip
|
|
644
658
|
conditional_path = conditional_stack.dup
|
|
659
|
+
conditional_path << current_branch_index if current_branch_index
|
|
645
660
|
directives << Howzit::Directive.new(
|
|
646
661
|
type: :log_level,
|
|
647
662
|
log_level_value: log_level,
|
|
@@ -664,6 +679,7 @@ module Howzit
|
|
|
664
679
|
# Remove quotes from value if present (handles both single and double quotes)
|
|
665
680
|
var_value = Regexp.last_match(1) if var_value =~ /^["'](.+)["']$/
|
|
666
681
|
conditional_path = conditional_stack.dup
|
|
682
|
+
conditional_path << current_branch_index if current_branch_index
|
|
667
683
|
directives << Howzit::Directive.new(
|
|
668
684
|
type: :set_var,
|
|
669
685
|
var_name: var_name,
|
|
@@ -818,12 +834,16 @@ module Howzit
|
|
|
818
834
|
|
|
819
835
|
# Handle @log_level directive (before task check)
|
|
820
836
|
if directive.log_level?
|
|
837
|
+
next unless directive_in_active_branch?(directive, conditional_state)
|
|
838
|
+
|
|
821
839
|
current_log_level = directive.log_level_value
|
|
822
840
|
next
|
|
823
841
|
end
|
|
824
842
|
|
|
825
843
|
# Handle @set_var directive (before task check)
|
|
826
844
|
if directive.set_var?
|
|
845
|
+
next unless directive_in_active_branch?(directive, conditional_state)
|
|
846
|
+
|
|
827
847
|
# Set the variable in named_arguments
|
|
828
848
|
Howzit.named_arguments ||= {}
|
|
829
849
|
value = directive.var_value
|
|
@@ -856,34 +876,7 @@ module Howzit
|
|
|
856
876
|
# Handle task directives
|
|
857
877
|
next unless directive.task?
|
|
858
878
|
|
|
859
|
-
|
|
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
|
|
879
|
+
next unless directive_in_active_branch?(directive, conditional_state)
|
|
887
880
|
|
|
888
881
|
# Convert directive to task
|
|
889
882
|
task = directive.to_task(self, current_log_level: current_log_level)
|
|
@@ -939,6 +932,38 @@ module Howzit
|
|
|
939
932
|
output
|
|
940
933
|
end
|
|
941
934
|
|
|
935
|
+
##
|
|
936
|
+
## Whether a directive nested under @if/@unless/@elsif/@else should run (same rules as tasks).
|
|
937
|
+
##
|
|
938
|
+
def directive_in_active_branch?(directive, conditional_state)
|
|
939
|
+
path = directive.conditional_path || []
|
|
940
|
+
return true if path.empty?
|
|
941
|
+
|
|
942
|
+
should_execute = true
|
|
943
|
+
path_to_check = path.dup
|
|
944
|
+
if path_to_check.length >= 2
|
|
945
|
+
last_idx = path_to_check.last
|
|
946
|
+
last_state = conditional_state[last_idx]
|
|
947
|
+
if last_state && %w[elsif else].include?(last_state[:directive_type])
|
|
948
|
+
parent_if_idx = path_to_check[-2]
|
|
949
|
+
parent_if_state = conditional_state[parent_if_idx]
|
|
950
|
+
if parent_if_state && %w[if unless].include?(parent_if_state[:directive_type])
|
|
951
|
+
path_to_check.delete(parent_if_idx)
|
|
952
|
+
end
|
|
953
|
+
end
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
path_to_check.each do |cond_idx|
|
|
957
|
+
cond_state = conditional_state[cond_idx]
|
|
958
|
+
if cond_state.nil? || !cond_state[:evaluated] || !cond_state[:result]
|
|
959
|
+
should_execute = false
|
|
960
|
+
break
|
|
961
|
+
end
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
should_execute
|
|
965
|
+
end
|
|
966
|
+
|
|
942
967
|
##
|
|
943
968
|
## Find the index of the matching @if/@unless for an @elsif/@else/@end
|
|
944
969
|
##
|
data/lib/howzit/util.rb
CHANGED
|
@@ -123,42 +123,37 @@ module Howzit
|
|
|
123
123
|
rescue Errno::EPIPE
|
|
124
124
|
# Pipe closed, ignore
|
|
125
125
|
end
|
|
126
|
-
return
|
|
126
|
+
return true
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
input = $stdin
|
|
132
|
-
|
|
133
|
-
pid = Kernel.fork do
|
|
134
|
-
write_io.close
|
|
135
|
-
input.reopen(read_io)
|
|
136
|
-
read_io.close
|
|
137
|
-
|
|
138
|
-
# Wait until we have input before we start the pager
|
|
139
|
-
IO.select [input]
|
|
140
|
-
|
|
141
|
-
pager = which_pager
|
|
142
|
-
|
|
129
|
+
pager = which_pager
|
|
130
|
+
if pager.nil?
|
|
143
131
|
begin
|
|
144
|
-
|
|
145
|
-
rescue
|
|
146
|
-
|
|
147
|
-
exit 1
|
|
132
|
+
puts text
|
|
133
|
+
rescue Errno::EPIPE
|
|
134
|
+
# Pipe closed, ignore
|
|
148
135
|
end
|
|
136
|
+
return true
|
|
149
137
|
end
|
|
150
138
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
139
|
+
# Write the full buffer to the pager stdin before closing it. The old
|
|
140
|
+
# fork + IO.select approach started less as soon as the first bytes
|
|
141
|
+
# arrived, which could leave the initial viewport scrolled past line 1.
|
|
142
|
+
IO.popen(pager, 'w') do |io|
|
|
143
|
+
io.write(text)
|
|
154
144
|
rescue Errno::EPIPE
|
|
155
145
|
# User quit pager before we finished writing, ignore
|
|
156
146
|
end
|
|
157
|
-
write_io.close
|
|
158
147
|
|
|
159
|
-
|
|
148
|
+
Process.last_status.success?
|
|
149
|
+
end
|
|
160
150
|
|
|
161
|
-
|
|
151
|
+
# Strip safely when external encoding rejects multibyte content (e.g. US-ASCII default).
|
|
152
|
+
def safe_strip(str)
|
|
153
|
+
s = str.to_s
|
|
154
|
+
s.strip
|
|
155
|
+
rescue Encoding::CompatibilityError
|
|
156
|
+
s.encode('UTF-8', invalid: :replace, undef: :replace).strip
|
|
162
157
|
end
|
|
163
158
|
|
|
164
159
|
# print output to terminal
|
|
@@ -180,7 +175,10 @@ module Howzit
|
|
|
180
175
|
pipes = "|#{hl}" if hl
|
|
181
176
|
end
|
|
182
177
|
|
|
183
|
-
|
|
178
|
+
string = safe_strip(string)
|
|
179
|
+
|
|
180
|
+
raw_output = `echo #{Shellwords.escape(string)}#{pipes}`
|
|
181
|
+
output = safe_strip(raw_output)
|
|
184
182
|
|
|
185
183
|
if options[:paginate] && Howzit.options[:paginate]
|
|
186
184
|
page(output)
|
data/lib/howzit/version.rb
CHANGED
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
|
data/spec/cli_spec.rb
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
describe 'includes: metadata' do
|
|
8
|
+
let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_includes_test')) }
|
|
9
|
+
let(:external_dir) { File.expand_path(File.join(temp_dir, 'external_project')) }
|
|
10
|
+
let(:main_dir) { File.expand_path(File.join(temp_dir, 'main_project')) }
|
|
11
|
+
|
|
12
|
+
before do
|
|
13
|
+
FileUtils.mkdir_p(external_dir)
|
|
14
|
+
FileUtils.mkdir_p(main_dir)
|
|
15
|
+
|
|
16
|
+
File.write(
|
|
17
|
+
File.join(external_dir, 'buildnotes.md'),
|
|
18
|
+
<<~NOTE
|
|
19
|
+
# External Project
|
|
20
|
+
|
|
21
|
+
## Shared Topic
|
|
22
|
+
|
|
23
|
+
@run(echo external) External task
|
|
24
|
+
|
|
25
|
+
## External Only
|
|
26
|
+
|
|
27
|
+
@run(echo only_external) Only in external
|
|
28
|
+
NOTE
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
File.write(
|
|
32
|
+
File.join(main_dir, 'buildnotes.md'),
|
|
33
|
+
<<~NOTE
|
|
34
|
+
includes: #{external_dir}
|
|
35
|
+
|
|
36
|
+
# Main Project
|
|
37
|
+
|
|
38
|
+
## Shared Topic
|
|
39
|
+
|
|
40
|
+
@run(echo local) Local task wins
|
|
41
|
+
|
|
42
|
+
## Main Only
|
|
43
|
+
|
|
44
|
+
@run(echo main) Main task
|
|
45
|
+
NOTE
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
Dir.chdir(main_dir)
|
|
49
|
+
Howzit.instance_variable_set(:@buildnote, nil)
|
|
50
|
+
Howzit.options[:stack] = false
|
|
51
|
+
Howzit.options[:include_upstream] = false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
after do
|
|
55
|
+
Dir.chdir(Dir.tmpdir)
|
|
56
|
+
FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
|
|
57
|
+
Howzit.instance_variable_set(:@buildnote, nil)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'loads topics from an included project directory' do
|
|
61
|
+
buildnote = Howzit::BuildNote.new
|
|
62
|
+
external = buildnote.find_topic('External Only')
|
|
63
|
+
|
|
64
|
+
expect(external).not_to be_empty
|
|
65
|
+
expect(external[0].title).to eq('external_project:External Only')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'prefers local topics over included topics with the same name' do
|
|
69
|
+
buildnote = Howzit::BuildNote.new
|
|
70
|
+
matches = buildnote.find_topic('Shared Topic')
|
|
71
|
+
|
|
72
|
+
expect(matches.length).to eq(1)
|
|
73
|
+
expect(matches[0].title).to eq('Shared Topic')
|
|
74
|
+
expect(matches[0].tasks.first.action).to include('local')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'includes topics from an explicit file path' do
|
|
78
|
+
shared_file = File.join(temp_dir, 'shared.md')
|
|
79
|
+
File.write(
|
|
80
|
+
shared_file,
|
|
81
|
+
<<~NOTE
|
|
82
|
+
# Shared file
|
|
83
|
+
|
|
84
|
+
## From File
|
|
85
|
+
|
|
86
|
+
@run(echo from_file) From file
|
|
87
|
+
NOTE
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
File.write(
|
|
91
|
+
File.join(main_dir, 'buildnotes.md'),
|
|
92
|
+
<<~NOTE
|
|
93
|
+
includes: #{shared_file}
|
|
94
|
+
|
|
95
|
+
# Main
|
|
96
|
+
|
|
97
|
+
## Local
|
|
98
|
+
NOTE
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
Howzit.instance_variable_set(:@buildnote, nil)
|
|
102
|
+
buildnote = Howzit::BuildNote.new
|
|
103
|
+
topic = buildnote.find_topic('From File')[0]
|
|
104
|
+
|
|
105
|
+
expect(topic.title).to eq('shared:From File')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'supports subtopic filters like templates' do
|
|
109
|
+
File.write(
|
|
110
|
+
File.join(main_dir, 'buildnotes.md'),
|
|
111
|
+
<<~NOTE
|
|
112
|
+
includes: #{external_dir}[External Only]
|
|
113
|
+
|
|
114
|
+
# Main
|
|
115
|
+
NOTE
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
Howzit.instance_variable_set(:@buildnote, nil)
|
|
119
|
+
buildnote = Howzit::BuildNote.new
|
|
120
|
+
|
|
121
|
+
expect(buildnote.find_topic('External Only')).not_to be_empty
|
|
122
|
+
expect(buildnote.find_topic('Shared Topic')).to be_empty
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -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
|
@@ -8,7 +8,6 @@ unless ENV['CI'] == 'true'
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
require 'howzit'
|
|
11
|
-
require 'cli-test'
|
|
12
11
|
|
|
13
12
|
RSpec.configure do |c|
|
|
14
13
|
c.expect_with(:rspec) { |e| e.syntax = :expect }
|
|
@@ -18,11 +17,19 @@ RSpec.configure do |c|
|
|
|
18
17
|
save_buildnote
|
|
19
18
|
# Reset buildnote cache to ensure fresh instance with updated file
|
|
20
19
|
Howzit.instance_variable_set(:@buildnote, nil)
|
|
20
|
+
Howzit.arguments = []
|
|
21
|
+
Howzit.cli_topic_positional_args = nil
|
|
22
|
+
Howzit.named_arguments = {}
|
|
21
23
|
Howzit.options[:include_upstream] = false
|
|
22
24
|
Howzit.options[:stack] = false
|
|
23
25
|
Howzit.options[:default] = true
|
|
24
26
|
Howzit.options[:matching] = 'partial'
|
|
25
27
|
Howzit.options[:multiple_matches] = 'choose'
|
|
28
|
+
# Point singleton at test fixture (repo may also contain buildnotes.md, etc.)
|
|
29
|
+
Howzit.instance_variable_set(
|
|
30
|
+
:@buildnote,
|
|
31
|
+
Howzit::BuildNote.new(file: File.expand_path('builda.md'))
|
|
32
|
+
)
|
|
26
33
|
@hz = Howzit.buildnote
|
|
27
34
|
end
|
|
28
35
|
|
|
@@ -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
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.
|
|
4
|
+
version: 2.1.42
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brett Terpstra
|
|
@@ -320,6 +320,7 @@ files:
|
|
|
320
320
|
- spec/condition_evaluator_spec.rb
|
|
321
321
|
- spec/conditional_blocks_integration_spec.rb
|
|
322
322
|
- spec/conditional_content_spec.rb
|
|
323
|
+
- spec/includes_metadata_spec.rb
|
|
323
324
|
- spec/log_level_spec.rb
|
|
324
325
|
- spec/prompt_spec.rb
|
|
325
326
|
- spec/ruby_gem_spec.rb
|
|
@@ -331,6 +332,7 @@ files:
|
|
|
331
332
|
- spec/stack_mode_spec.rb
|
|
332
333
|
- spec/stringutils_spec.rb
|
|
333
334
|
- spec/task_spec.rb
|
|
335
|
+
- spec/topic_positional_snapshot_spec.rb
|
|
334
336
|
- spec/topic_spec.rb
|
|
335
337
|
- spec/util_spec.rb
|
|
336
338
|
- src/_README.md
|
|
@@ -354,7 +356,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
354
356
|
- !ruby/object:Gem::Version
|
|
355
357
|
version: '0'
|
|
356
358
|
requirements: []
|
|
357
|
-
rubygems_version:
|
|
359
|
+
rubygems_version: 3.6.7
|
|
358
360
|
specification_version: 4
|
|
359
361
|
summary: Provides a way to access Markdown project notes by topic with query capabilities
|
|
360
362
|
and the ability to execute the tasks it describes.
|
|
@@ -364,6 +366,7 @@ test_files:
|
|
|
364
366
|
- spec/condition_evaluator_spec.rb
|
|
365
367
|
- spec/conditional_blocks_integration_spec.rb
|
|
366
368
|
- spec/conditional_content_spec.rb
|
|
369
|
+
- spec/includes_metadata_spec.rb
|
|
367
370
|
- spec/log_level_spec.rb
|
|
368
371
|
- spec/prompt_spec.rb
|
|
369
372
|
- spec/ruby_gem_spec.rb
|
|
@@ -375,5 +378,6 @@ test_files:
|
|
|
375
378
|
- spec/stack_mode_spec.rb
|
|
376
379
|
- spec/stringutils_spec.rb
|
|
377
380
|
- spec/task_spec.rb
|
|
381
|
+
- spec/topic_positional_snapshot_spec.rb
|
|
378
382
|
- spec/topic_spec.rb
|
|
379
383
|
- spec/util_spec.rb
|