├── .rspec ├── fish ├── completions │ ├── fisher.fish │ ├── bld.fish │ └── howzit.fish └── functions │ └── bld.fish ├── lib ├── .rubocop.yml ├── howzit │ ├── version.rb │ ├── hash.rb │ ├── console_logger.rb │ ├── run_report.rb │ ├── task.rb │ ├── config.rb │ ├── util.rb │ ├── colors.rb │ ├── prompt.rb │ ├── topic.rb │ ├── stringutils.rb │ └── buildnote.rb └── howzit.rb ├── .yardopts ├── spec ├── .rubocop.yml ├── ruby_gem_spec.rb ├── task_spec.rb ├── cli_spec.rb ├── prompt_spec.rb ├── util_spec.rb ├── run_report_spec.rb ├── spec_helper.rb ├── topic_spec.rb └── buildnote_spec.rb ├── README.rdoc ├── .github └── FUNDING.yml ├── scripts └── runtests.sh ├── .editorconfig ├── Gemfile ├── .irbrc ├── docker ├── bash_profile ├── Dockerfile ├── Dockerfile-2.6 ├── Dockerfile-2.7 ├── Dockerfile-3.0 ├── Dockerfile-3.2 └── inputrc ├── .travis.yml ├── Guardfile ├── update_readmes.rb ├── .rubocop.yml ├── .gitignore ├── LICENSE.txt ├── howzit.gemspec ├── Rakefile ├── README.md ├── src └── _README.md ├── bin └── howzit └── CHANGELOG.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /fish/completions/fisher.fish: -------------------------------------------------------------------------------- 1 | fisher complete 2 | -------------------------------------------------------------------------------- /lib/.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: ../.rubocop.yml 2 | -------------------------------------------------------------------------------- /fish/completions/bld.fish: -------------------------------------------------------------------------------- 1 | complete -xc bld -a "(howzit -T)" 2 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup=markdown 2 | lib/**/*.rb 3 | - 4 | README.md 5 | CHANGELOG.md 6 | LICENSE.txt 7 | -------------------------------------------------------------------------------- /spec/.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: ../.rubocop.yml 2 | 3 | Style/StringLiterals: 4 | Enabled: false 5 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = Howzit 2 | 3 | A command-line reference tool for tracking project build systems 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ttscoff] 2 | custom: ['https://brettterpstra.com/support/', 'https://brettterpstra.com/donate/'] 3 | -------------------------------------------------------------------------------- /scripts/runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | bundle install 4 | export EDITOR="/usr/bin/vim" 5 | export PATH=$PATH:$GEM_HOME/bin 6 | rake spec 7 | -------------------------------------------------------------------------------- /lib/howzit/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Primary module for this gem. 4 | module Howzit 5 | # Current Howzit version. 6 | VERSION = '2.1.25' 7 | end 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'rubocop-rake', require: false 6 | gem 'rubocop-rspec', require: false 7 | 8 | # Specify your gem's dependencies in howzit.gemspec. 9 | gemspec 10 | -------------------------------------------------------------------------------- /fish/functions/bld.fish: -------------------------------------------------------------------------------- 1 | function fallback --description 'allow a fallback value for variable' 2 | if test (count $argv) = 1 3 | echo $argv 4 | else 5 | echo $argv[1..-2] 6 | end 7 | end 8 | 9 | function bld -d "Run howzit build system" 10 | howzit -r (fallback $argv build) 11 | end 12 | -------------------------------------------------------------------------------- /.irbrc: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | IRB.conf[:AUTO_INDENT] = true 3 | 4 | require "irb/completion" 5 | require_relative "lib/conductor" 6 | 7 | # rubocop:disable Style/MixinUsage 8 | include Howzit # standard:disable all 9 | # rubocop:enable Style/MixinUsage 10 | 11 | require "awesome_print" 12 | AwesomePrint.irb! 13 | -------------------------------------------------------------------------------- /spec/ruby_gem_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Howzit::BuildNote do 6 | subject(:buildnote) { Howzit.buildnote } 7 | 8 | describe ".new" do 9 | it "makes a new instance" do 10 | expect(buildnote).to be_a Howzit::BuildNote 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /docker/bash_profile: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export GLI_DEBUG=true 3 | export EDITOR="/usr/bin/vim" 4 | export PATH=$PATH:$GEM_HOME/bin 5 | alias b="bundle exec bin/howzit" 6 | 7 | shopt -s nocaseglob 8 | shopt -s histappend 9 | shopt -s histreedit 10 | shopt -s histverify 11 | shopt -s cmdhist 12 | 13 | cd /howzit 14 | bundle install 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | sudo: required 4 | dist: trusty 5 | cache: bundler 6 | rvm: 7 | - ruby-2.6.4 8 | - ruby-2.7.0 9 | - ruby-3.0.1 10 | install: 11 | - gem install bundler --version '2.2.29' 12 | - bundle install 13 | script: "bundle exec rspec spec --exclude-pattern 'cli*'" 14 | branches: 15 | only: 16 | - main 17 | - develop 18 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.0.1 2 | # RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - 3 | RUN mkdir /howzit 4 | WORKDIR /howzit 5 | # COPY ./ /howzit/ 6 | RUN gem install bundler:2.2.17 7 | RUN apt-get update -y 8 | RUN apt-get install -y less vim 9 | COPY ./docker/inputrc /root/.inputrc 10 | COPY ./docker/bash_profile /root/.bash_profile 11 | CMD ["scripts/runtests.sh"] 12 | -------------------------------------------------------------------------------- /docker/Dockerfile-2.6: -------------------------------------------------------------------------------- 1 | FROM ruby:2.6 2 | # RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - 3 | RUN mkdir /howzit 4 | WORKDIR /howzit 5 | # COPY ./ /howzit/ 6 | RUN gem install bundler:2.2.29 7 | RUN apt-get update -y 8 | RUN apt-get install -y less vim 9 | COPY ./docker/inputrc /root/.inputrc 10 | COPY ./docker/bash_profile /root/.bash_profile 11 | CMD ["scripts/runtests.sh"] 12 | -------------------------------------------------------------------------------- /docker/Dockerfile-2.7: -------------------------------------------------------------------------------- 1 | FROM ruby:2.7 2 | # RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - 3 | RUN mkdir /howzit 4 | WORKDIR /howzit 5 | # COPY ./ /howzit/ 6 | RUN gem install bundler:2.2.29 7 | RUN apt-get update -y 8 | RUN apt-get install -y less vim 9 | COPY ./docker/inputrc /root/.inputrc 10 | COPY ./docker/bash_profile /root/.bash_profile 11 | CMD ["scripts/runtests.sh"] 12 | -------------------------------------------------------------------------------- /docker/Dockerfile-3.0: -------------------------------------------------------------------------------- 1 | FROM ruby:3.0.0 2 | # RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - 3 | RUN mkdir /howzit 4 | WORKDIR /howzit 5 | # COPY ./ /howzit/ 6 | RUN gem install bundler:2.2.29 7 | RUN apt-get update -y 8 | RUN apt-get install -y less vim 9 | COPY ./docker/inputrc /root/.inputrc 10 | COPY ./docker/bash_profile /root/.bash_profile 11 | CMD ["scripts/runtests.sh"] 12 | -------------------------------------------------------------------------------- /docker/Dockerfile-3.2: -------------------------------------------------------------------------------- 1 | FROM ruby:3.2.0 2 | # RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - 3 | RUN mkdir /howzit 4 | WORKDIR /howzit 5 | # COPY ./ /howzit/ 6 | RUN gem install bundler:2.2.29 7 | RUN apt-get update -y 8 | RUN apt-get install -y less vim 9 | COPY ./docker/inputrc /root/.inputrc 10 | COPY ./docker/bash_profile /root/.bash_profile 11 | CMD ["scripts/runtests.sh"] 12 | -------------------------------------------------------------------------------- /spec/task_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Howzit::Task do 6 | subject(:task) do 7 | Howzit::Task.new({ type: :run, 8 | title: 'List Directory', 9 | action: 'ls &> /dev/null' }) 10 | end 11 | 12 | describe ".new" do 13 | it "makes a new task instance" do 14 | expect(task).to be_a Howzit::Task 15 | end 16 | end 17 | 18 | describe ".to_s" do 19 | it "outputs title string" do 20 | expect(task.to_s).to match(/List Directory/) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | scope groups: %i[doc lint unit] 4 | 5 | group :doc do 6 | guard :yard do 7 | watch(%r{^lib/(.+)\.rb$}) 8 | end 9 | end 10 | 11 | group :lint do 12 | guard :rubocop do 13 | watch(%r{.+\.rb$}) 14 | watch(%r{(?:.+/)?\.rubocop\.yml$}) { |m| File.dirname(m[0]) } 15 | end 16 | end 17 | 18 | group :unit do 19 | guard :rspec, cmd: 'bundle exec rspec --color --format Fuubar' do 20 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 21 | watch(%r{^lib/howzit/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 22 | watch(%r{^spec/.+_spec\.rb$}) 23 | watch('spec/spec_helper.rb') { 'spec' } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /update_readmes.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | readme = IO.read('README.md') 4 | blog_project = '/Users/ttscoff/Sites/dev/bt/source/_projects/howzit.md' 5 | blog_changelog = '/Users/ttscoff/Sites/dev/bt/source/_projects/changelogs/howzit.md' 6 | 7 | project = readme.match(/(.*?)/m)[0] 8 | changelog = readme.match(/(.*?)/m)[1] 9 | blog_project_content = IO.read(blog_project) 10 | blog_project_content.sub!(/(.*?)/m, project) 11 | File.open(blog_project, 'w') { |f| f.puts blog_project_content } 12 | File.open(blog_changelog, 'w') { |f| f.puts changelog.strip } 13 | puts "Updated project file and changelog for BrettTerpstra.com" 14 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rspec 3 | - rubocop-rake 4 | 5 | AllCops: 6 | Include: 7 | - Gemfile 8 | - Guardfile 9 | - Rakefile 10 | - bin/howzit 11 | - lib/**/*.rb 12 | 13 | Style/StringLiterals: 14 | Enabled: true 15 | EnforcedStyle: single_quotes 16 | 17 | Style/StringLiteralsInInterpolation: 18 | Enabled: true 19 | EnforcedStyle: single_quotes 20 | 21 | Layout/LineLength: 22 | Max: 120 23 | 24 | Metrics/MethodLength: 25 | Max: 45 26 | 27 | Metrics/BlockLength: 28 | Max: 45 29 | Exclude: 30 | - Rakefile 31 | - bin/howzit 32 | - lib/*.rb 33 | 34 | Metrics/ClassLength: 35 | Max: 300 36 | 37 | Metrics/PerceivedComplexity: 38 | Max: 30 39 | 40 | Metrics/AbcSize: 41 | Max: 45 42 | 43 | Metrics/CyclomaticComplexity: 44 | Max: 20 45 | 46 | Style/RegexpLiteral: 47 | Exclude: 48 | - Guardfile 49 | -------------------------------------------------------------------------------- /spec/cli_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | # https://github.com/thoiberg/cli-test 6 | describe 'CLI' do 7 | include CliTest 8 | 9 | it 'executes successfully' do 10 | execute_script('bin/howzit', use_bundler: true) 11 | expect(last_execution).to be_successful 12 | end 13 | 14 | it 'lists available topics' do 15 | execute_script('bin/howzit', use_bundler: true, args: %w[-L]) 16 | expect(last_execution).to be_successful 17 | expect(last_execution.stdout).to match(/Topic Balogna/) 18 | expect(last_execution.stdout.split(/\n/).count).to eq 7 19 | end 20 | 21 | it 'lists available tasks' do 22 | execute_script('bin/howzit', use_bundler: true, args: %w[-T]) 23 | expect(last_execution).to be_successful 24 | expect(last_execution.stdout).to match(/Topic Balogna/) 25 | expect(last_execution.stdout.split(/\n/).count).to eq 2 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Parts of this file were adapted from 2 | # GitHub’s collection of .gitignore file templates 3 | # which are Copyright (c) 2016 GitHub, Inc. 4 | # and released under the MIT License. 5 | # For more details, visit the project page: 6 | # https://github.com/github/gitignore 7 | 8 | *.gem 9 | *.rbc 10 | /.config 11 | /coverage/ 12 | /InstalledFiles 13 | /pkg/ 14 | /spec/reports/ 15 | /spec/examples.txt 16 | /test/tmp/ 17 | /test/version_tmp/ 18 | /tmp/ 19 | 20 | ## Specific to RubyMotion: 21 | .dat* 22 | .repl_history 23 | build/ 24 | 25 | ## Documentation cache and generated files: 26 | /.yardoc/ 27 | /_yardoc/ 28 | /doc/ 29 | /rdoc/ 30 | 31 | ## Environment normalization: 32 | /.bundle/ 33 | /vendor/bundle 34 | /lib/bundler/man/ 35 | 36 | # for a library or gem, you might want to ignore these files since the code is 37 | # intended to run in multiple environments; otherwise, check them in: 38 | Gemfile.lock 39 | .ruby-version 40 | .ruby-gemset 41 | 42 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 43 | .rvmrc 44 | results.log 45 | html 46 | .vscode 47 | *.bak 48 | commit_message.txt 49 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Brett Terpstra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /spec/prompt_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Howzit::Prompt do 6 | subject(:prompt) { Howzit::Prompt } 7 | 8 | describe '.yn' do 9 | it 'returns default response' do 10 | Howzit.options[:default] = true 11 | expect(prompt.yn('Test prompt', default: true)).to be_truthy 12 | expect(prompt.yn('Test prompt', default: false)).not_to be_truthy 13 | end 14 | end 15 | 16 | describe '.color_single_options' do 17 | it 'returns uncolored string' do 18 | Howzit::Color.coloring = false 19 | expect(prompt.color_single_options(%w[y n])).to eq "[y/n]" 20 | end 21 | end 22 | 23 | describe '.options_list' do 24 | it 'creates a formatted list of options' do 25 | options = %w[one two three four five].each_with_object([]) do |x, arr| 26 | arr << "Option item #{x}" 27 | end 28 | expect { prompt.options_list(options) }.to output(/ 2 \) Option item two/).to_stdout 29 | end 30 | end 31 | 32 | describe '.choose' do 33 | it 'returns a single match' do 34 | expect(prompt.choose(['option 1']).count).to eq 1 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/util_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Howzit::Util do 6 | subject(:util) { Howzit::Util } 7 | 8 | describe '.read_file' do 9 | it 'reads file to a string' do 10 | buildnote = util.read_file('builda.md') 11 | expect(buildnote).not_to be_empty 12 | expect(buildnote).to be_a String 13 | end 14 | end 15 | 16 | describe '.valid_command?' do 17 | it 'finds a command' do 18 | expect(util.command_exist?('ls')).to be_truthy 19 | end 20 | it 'validates a command' do 21 | expect(util.valid_command?('ls -1')).to be_truthy 22 | end 23 | end 24 | 25 | describe '.which_highlighter' do 26 | it 'finds mdless' do 27 | Howzit.options[:highlighter] = 'mdless' 28 | expect(util.which_highlighter).to eq 'mdless' 29 | end 30 | end 31 | 32 | describe '.which_pager' do 33 | it 'finds the more utility' do 34 | Howzit.options[:pager] = 'more' 35 | expect(util.which_pager).to eq 'more' 36 | Howzit.options[:pager] = 'auto' 37 | expect(util.which_pager).to_not eq 'more' 38 | end 39 | end 40 | 41 | describe '.show' do 42 | it 'prints output' do 43 | buildnote = util.read_file('builda.md') 44 | expect { util.show(buildnote) }.to output(/Balogna/).to_stdout 45 | end 46 | 47 | it 'pages output' do 48 | buildnote = util.read_file('builda.md') 49 | expect { util.page(buildnote) }.to output(/Balogna/).to_stdout 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/howzit/hash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Hash helpers 4 | class ::Hash 5 | ## 6 | ## Freeze all values in a hash 7 | ## 8 | ## @return [Hash] Hash with all values frozen 9 | ## 10 | def deep_freeze 11 | chilled = {} 12 | each do |k, v| 13 | chilled[k] = v.is_a?(Hash) ? v.deep_freeze : v.freeze 14 | end 15 | 16 | chilled.freeze 17 | end 18 | 19 | ## 20 | ## Deep freeze a hash in place (destructive) 21 | ## 22 | def deep_freeze! 23 | replace deep_thaw.deep_freeze 24 | end 25 | 26 | ## 27 | ## Unfreeze nested hash values 28 | ## 29 | ## @return [Hash] Hash with all values unfrozen 30 | ## 31 | def deep_thaw 32 | chilled = {} 33 | each do |k, v| 34 | chilled[k] = v.is_a?(Hash) ? v.deep_thaw : v.dup 35 | end 36 | 37 | chilled.dup 38 | end 39 | 40 | ## 41 | ## Unfreeze nested hash values in place (destructive) 42 | ## 43 | def deep_thaw! 44 | replace deep_thaw 45 | end 46 | 47 | # Turn all keys into string 48 | # 49 | # @return [Hash] hash with all keys as strings 50 | # 51 | def stringify_keys 52 | each_with_object({}) { |(k, v), hsh| hsh[k.to_s] = v.is_a?(Hash) ? v.stringify_keys : v } 53 | end 54 | 55 | ## 56 | ## Turn all keys into strings in place (destructive) 57 | ## 58 | def stringify_keys! 59 | replace stringify_keys 60 | end 61 | 62 | # Turn all keys into symbols 63 | # 64 | # @return [Hash] hash with all keys as symbols 65 | # 66 | def symbolize_keys 67 | each_with_object({}) { |(k, v), hsh| hsh[k.to_sym] = v.is_a?(Hash) ? v.symbolize_keys : v } 68 | end 69 | 70 | ## 71 | ## Turn all keys into symbols in place (destructive) 72 | ## 73 | def symbolize_keys! 74 | replace symbolize_keys 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/howzit/console_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Available log levels 4 | LOG_LEVELS = { 5 | debug: 0, 6 | info: 1, 7 | warn: 2, 8 | error: 3 9 | }.deep_freeze 10 | 11 | module Howzit 12 | # Console logging 13 | class ConsoleLogger 14 | attr_accessor :log_level 15 | 16 | ## 17 | ## Init the console logging object 18 | ## 19 | ## @param level [Integer] log level 20 | ## 21 | def initialize(level = nil) 22 | @log_level = level.to_i || Howzit.options[:log_level] 23 | end 24 | 25 | ## 26 | ## Get the log level from options 27 | ## 28 | ## @return [Integer] log level 29 | ## 30 | def reset_level 31 | @log_level = Howzit.options[:log_level] 32 | end 33 | 34 | ## 35 | ## Write a message to the console based on the urgency 36 | ## level and user's log level setting 37 | ## 38 | ## @param msg [String] The message 39 | ## @param level [Symbol] The level 40 | ## 41 | def write(msg, level = :info) 42 | $stderr.puts msg if LOG_LEVELS[level] >= @log_level 43 | end 44 | 45 | ## 46 | ## Write a message at debug level 47 | ## 48 | ## @param msg The message 49 | ## 50 | def debug(msg) 51 | write msg, :debug 52 | end 53 | 54 | ## 55 | ## Write a message at info level 56 | ## 57 | ## @param msg The message 58 | ## 59 | def info(msg) 60 | write msg, :info 61 | end 62 | 63 | ## 64 | ## Write a message at warn level 65 | ## 66 | ## @param msg The message 67 | ## 68 | def warn(msg) 69 | write msg, :warn 70 | end 71 | 72 | ## 73 | ## Write a message at error level 74 | ## 75 | ## @param msg The message 76 | ## 77 | def error(msg) 78 | write msg, :error 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /docker/inputrc: -------------------------------------------------------------------------------- 1 | "\e[3~": delete-char 2 | "\ex": 'cd !$ \015ls\015' 3 | "\ez": 'cd -\015' 4 | "\e\C-m": '\C-a "$(\C-e|fzf)"\C-a' 5 | "\e/": '"$(!!|fzf)"\C-a \C-m\C-m' 6 | # these allow you to use alt+left/right arrow keys 7 | # to jump the cursor over words 8 | "\e[1;5C": forward-word 9 | "\e[1;5D": backward-word 10 | # "\e[D": backward-word 11 | # "\e[C": forward-word 12 | "\ea": menu-complete 13 | # TAB: menu-complete 14 | # "\e[Z": "\e-1\C-i" 15 | 16 | "\e\C-l": history-and-alias-expand-line 17 | 18 | # these allow you to start typing a command and 19 | # use the up/down arrow to auto complete from 20 | # commands in your history 21 | "\e[B": history-search-forward 22 | "\e[A": history-search-backward 23 | "\ew": history-search-backward 24 | "\es": history-search-forward 25 | # this lets you hit tab to auto-complete a file or 26 | # directory name ignoring case 27 | set completion-ignore-case On 28 | set mark-symlinked-directories On 29 | set completion-prefix-display-length 2 30 | set bell-style none 31 | # set bell-style visible 32 | set meta-flag on 33 | set convert-meta off 34 | set input-meta on 35 | set output-meta on 36 | set show-all-if-ambiguous on 37 | set show-all-if-unmodified on 38 | set completion-map-case on 39 | set visible-stats on 40 | 41 | # Do history expansion when space entered? 42 | $if bash 43 | Space: magic-space 44 | $endif 45 | 46 | # Show extra file information when completing, like `ls -F` does 47 | set visible-stats on 48 | 49 | # Be more intelligent when autocompleting by also looking at the text after 50 | # the cursor. For example, when the current line is "cd ~/src/mozil", and 51 | # the cursor is on the "z", pressing Tab will not autocomplete it to "cd 52 | # ~/src/mozillail", but to "cd ~/src/mozilla". (This is supported by the 53 | # Readline used by Bash 4.) 54 | set skip-completed-text on 55 | 56 | # Use Alt/Meta + Delete to delete the preceding word 57 | "\e[3;3~": kill-word 58 | -------------------------------------------------------------------------------- /spec/run_report_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Howzit::RunReport do 6 | before do 7 | Howzit.run_log = [] 8 | Howzit.multi_topic_run = false 9 | end 10 | 11 | after do 12 | Howzit::RunReport.reset 13 | Howzit.multi_topic_run = false 14 | end 15 | 16 | it 'renders a simple list for single topic runs' do 17 | Howzit::RunReport.log({ topic: 'Git: Config', task: 'Run Git Origin', success: true, exit_status: 0 }) 18 | plain = Howzit::RunReport.format.uncolor 19 | expect(plain).to include('***') 20 | expect(plain).to include('✅') 21 | expect(plain).to include('Run Git Origin') 22 | expect(plain).not_to include('Git: Config:') 23 | end 24 | 25 | it 'prefixes topic titles and shows failures when multiple topics run' do 26 | Howzit.multi_topic_run = true 27 | Howzit::RunReport.log({ topic: 'Git: Config', task: 'Run Git Origin', success: true, exit_status: 0 }) 28 | Howzit::RunReport.log({ topic: 'Git: Clean Repo', task: 'Clean Git Repo', success: false, exit_status: 12 }) 29 | plain = Howzit::RunReport.format.uncolor 30 | expect(plain).to include('✅') 31 | expect(plain).to include('Git: Config: Run Git Origin') 32 | expect(plain).to include('❌') 33 | expect(plain).to include('Git: Clean Repo: Clean Git Repo') 34 | expect(plain).to include('exit code 12') 35 | end 36 | 37 | it 'formats as a proper markdown table with aligned columns using format_as_table' do 38 | Howzit::RunReport.log({ topic: 'Test', task: 'Short', success: true, exit_status: 0 }) 39 | Howzit::RunReport.log({ topic: 'Test', task: 'A much longer task name', success: true, exit_status: 0 }) 40 | plain = Howzit::RunReport.format_as_table.uncolor 41 | lines = plain.split("\n") 42 | # All lines should start and end with pipe 43 | lines.each do |line| 44 | expect(line).to start_with('|') 45 | expect(line).to end_with('|') 46 | end 47 | # Second line should be separator 48 | expect(lines[1]).to match(/^\|[\s:-]+\|[\s:-]+\|$/) 49 | end 50 | end 51 | 52 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | unless ENV['CI'] == 'true' 4 | # SimpleCov::Formatter::Codecov # For CI 5 | require 'simplecov' 6 | SimpleCov.formatter = SimpleCov::Formatter::HTMLFormatter 7 | SimpleCov.start 8 | end 9 | 10 | require 'howzit' 11 | require 'cli-test' 12 | 13 | RSpec.configure do |c| 14 | c.expect_with(:rspec) { |e| e.syntax = :expect } 15 | 16 | c.before(:each) do 17 | allow(FileUtils).to receive(:remove_entry_secure).with(anything) 18 | save_buildnote 19 | Howzit.options[:include_upstream] = false 20 | Howzit.options[:default] = true 21 | Howzit.options[:matching] = 'partial' 22 | Howzit.options[:multiple_matches] = 'choose' 23 | @hz = Howzit.buildnote 24 | end 25 | 26 | c.after(:each) do 27 | delete_buildnote 28 | end 29 | end 30 | 31 | def save_buildnote 32 | note = <<~EONOTE 33 | defined: this is defined 34 | 35 | # Howzit Test 36 | 37 | ## Topic Balogna 38 | 39 | @before 40 | This should be a prerequisite. 41 | @end 42 | 43 | @run(ls -1 &> /dev/null) Null Output 44 | @include(Topic Tropic) 45 | 46 | ```run 47 | #!/usr/bin/env ruby 48 | title = "[%undefined]".empty? ? "[%defined]" : "[%undefined]" 49 | ``` 50 | 51 | @after 52 | This should be a postrequisite. 53 | @end 54 | 55 | ## Topic Banana 56 | 57 | This is just another topic. 58 | 59 | - It has a list in it 60 | - That's pretty fun, right? 61 | - Defined: '[%defined]' 62 | - Undefined: '[%undefined]' 63 | 64 | ## Topic Tropic 65 | 66 | Bermuda, Bahama, something something wanna. 67 | @copy(Balogna) Just some balogna 68 | 69 | ## Happy Bgagngagnga 70 | 71 | This one is just to throw things off 72 | 73 | ## Git: Clean Repo 74 | 75 | Keep Git projects tidy. 76 | 77 | ## Blog: Update Post 78 | 79 | Publish the latest article updates. 80 | 81 | ## Release, Deploy 82 | 83 | Prep and deploy the latest release. 84 | EONOTE 85 | File.open('builda.md', 'w') { |f| f.puts note } 86 | # puts "Saved to builda.md: #{File.exist?('builda.md')}" 87 | end 88 | 89 | def delete_buildnote 90 | FileUtils.rm('builda.md') 91 | end 92 | -------------------------------------------------------------------------------- /howzit.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path(File.join('..', 'lib'), __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'howzit/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'howzit' 9 | spec.version = Howzit::VERSION 10 | spec.authors = ['Brett Terpstra'] 11 | spec.email = ['me@brettterpstra.com'] 12 | spec.description = 'Command line project documentation and task runner' 13 | spec.summary = ['Provides a way to access Markdown project notes by topic', 14 | 'with query capabilities and the ability to execute the', 15 | 'tasks it describes.'].join(' ') 16 | spec.homepage = 'https://github.com/ttscoff/howzit' 17 | spec.license = 'MIT' 18 | 19 | spec.files = `git ls-files -z`.split("\x0").reject do |path| 20 | path.split('/').any? { |segment| segment.start_with?('.') } || path.end_with?('.bak') 21 | end 22 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 23 | spec.test_files = spec.files.grep(%r{^(features|spec|test)/}) 24 | spec.require_paths = ['lib'] 25 | 26 | spec.required_ruby_version = '>= 2.6.0' 27 | 28 | spec.add_development_dependency 'bundler', '~> 2.2' 29 | spec.add_development_dependency 'rake', '~> 13.0' 30 | 31 | spec.add_development_dependency 'guard', '~> 2.11' 32 | spec.add_development_dependency 'guard-rspec', '~> 4.5' 33 | spec.add_development_dependency 'guard-rubocop', '~> 1.2' 34 | spec.add_development_dependency 'guard-yard', '~> 2.1' 35 | 36 | spec.add_development_dependency 'cli-test', '~> 1.0' 37 | spec.add_development_dependency 'rspec', '~> 3.13' 38 | spec.add_development_dependency 'rubocop', '~> 0.28' 39 | spec.add_development_dependency 'simplecov', '~> 0.9' 40 | # spec.add_development_dependency 'codecov', '~> 0.1' 41 | spec.add_development_dependency 'fuubar', '~> 2.0' 42 | 43 | spec.add_development_dependency 'github-markup', '~> 1.3' 44 | spec.add_development_dependency 'redcarpet', '~> 3.2' 45 | spec.add_development_dependency 'tty-spinner', '~> 0.9' 46 | spec.add_development_dependency 'yard', '~> 0.9.5' 47 | 48 | spec.add_runtime_dependency 'mdless', '~> 1.0', '>= 1.0.28' 49 | spec.add_runtime_dependency 'tty-box', '~> 0.7' 50 | spec.add_runtime_dependency 'tty-screen', '~> 0.8' 51 | # spec.add_runtime_dependency 'tty-prompt', '~> 0.23' 52 | end 53 | -------------------------------------------------------------------------------- /lib/howzit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Main config dir 4 | CONFIG_DIR = '~/.config/howzit' 5 | 6 | # Config file name 7 | CONFIG_FILE = 'howzit.yaml' 8 | 9 | # Color template name 10 | COLOR_FILE = 'theme.yaml' 11 | 12 | # Ignore file name 13 | IGNORE_FILE = 'ignore.yaml' 14 | 15 | # Available options for matching method 16 | MATCHING_OPTIONS = %w[partial exact fuzzy beginswith].freeze 17 | 18 | # Available options for multiple_matches method 19 | MULTIPLE_OPTIONS = %w[first best all choose].freeze 20 | 21 | # Available options for header formatting 22 | HEADER_FORMAT_OPTIONS = %w[border block].freeze 23 | 24 | require 'optparse' 25 | require 'shellwords' 26 | require 'pathname' 27 | require 'readline' 28 | require 'tempfile' 29 | require 'yaml' 30 | 31 | require_relative 'howzit/util' 32 | require_relative 'howzit/hash' 33 | 34 | require_relative 'howzit/version' 35 | require_relative 'howzit/prompt' 36 | require_relative 'howzit/colors' 37 | require_relative 'howzit/stringutils' 38 | 39 | require_relative 'howzit/console_logger' 40 | require_relative 'howzit/config' 41 | require_relative 'howzit/task' 42 | require_relative 'howzit/topic' 43 | require_relative 'howzit/buildnote' 44 | require_relative 'howzit/run_report' 45 | 46 | require 'tty/screen' 47 | require 'tty/box' 48 | # require 'tty/prompt' 49 | 50 | # Main module for howzit 51 | module Howzit 52 | class << self 53 | attr_accessor :arguments, :named_arguments, :cli_args, :run_log, :multi_topic_run 54 | 55 | ## 56 | ## Holds a Configuration object with methods and a @settings hash 57 | ## 58 | ## @return [Configuration] Configuration object 59 | ## 60 | def config 61 | @config ||= Config.new 62 | end 63 | 64 | ## 65 | ## Array for tracking inclusions and avoiding duplicates in output 66 | ## 67 | def inclusions 68 | @inclusions ||= [] 69 | end 70 | 71 | ## 72 | ## Module storage for Howzit::Config.options 73 | ## 74 | def options 75 | config.options 76 | end 77 | 78 | ## 79 | ## Module storage for buildnote 80 | ## 81 | def buildnote(file = nil) 82 | @buildnote ||= BuildNote.new(file: file) 83 | end 84 | 85 | ## 86 | ## Convenience method for logging with Howzit.console.warn, etc. 87 | ## 88 | def console 89 | @console ||= Howzit::ConsoleLogger.new(options[:log_level]) 90 | end 91 | 92 | def run_log 93 | @run_log ||= [] 94 | end 95 | 96 | def multi_topic_run 97 | @multi_topic_run ||= false 98 | end 99 | 100 | def has_read_upstream 101 | @has_read_upstream ||= false 102 | end 103 | 104 | attr_writer :has_read_upstream, :run_log 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /fish/completions/howzit.fish: -------------------------------------------------------------------------------- 1 | complete -xc howzit -a "(howzit -L)" 2 | complete -c howzit -l default -d "Answer all prompts with default response" 3 | complete -c howzit -x -s m -l matching -a "partial exact fuzzy beginswith" -d "Topics matching type" 4 | complete -c howzit -x -l multiple -a "first best all choose" -d "Multiple result handling" 5 | complete -c howzit -s u -l no-upstream -d "Don't traverse up parent directories for additional build notes" 6 | complete -c howzit -s u -l upstream -d "Traverse up parent directories for additional build notes" 7 | complete -c howzit -s L -l list-completions -d "List topics for completion" 8 | complete -c howzit -s l -l list -d "List available topics" 9 | complete -c howzit -s R -l list-runnable -d "List topics containing @ directives (verbose)" 10 | complete -c howzit -s T -l task-list -d "List topics containing @ directives (completion-compatible)" 11 | complete -c howzit -l templates -d "List available templates" 12 | complete -c howzit -l title-only -d "Output title only" 13 | complete -c howzit -s c -l create -d "Create a skeleton build note in the current working directory" 14 | complete -c howzit -f -l config-get -d "Display the configuration settings or setting for a specific key" 15 | complete -c howzit -f -l hook -d "Copy a link to the build notes file (macOS)" 16 | complete -c howzit -f -l config-set -d "Set a config value (must be a valid key)" 17 | complete -c howzit -l edit-config -d "Edit configuration file using editor (subl)" 18 | complete -c howzit -s e -l edit -d "Edit buildnotes file in current working directory using editor (subl)" 19 | complete -c howzit -x -l grep -d "Display sections matching a search pattern" 20 | complete -c howzit -s r -l run -a "(howzit -T)" -d "Execute @run, @open, and/or @copy commands for given topic" 21 | complete -c howzit -s s -l select -d "Select topic from menu" 22 | complete -c howzit -l color -d "Colorize output (default on)" 23 | complete -c howzit -l no-color -d "Don't colorize output (default on)" 24 | complete -c howzit -x -l header-format -d "Formatting style for topic titles (border, block)" 25 | complete -c howzit -l md-highlight -d "Highlight Markdown syntax (default on), requires mdless or mdcat" 26 | complete -c howzit -l no-md-highlight -d "Don't highlight Markdown syntax (default on), requires mdless or mdcat" 27 | complete -c howzit -l pager -d "Paginate output (default on)" 28 | complete -c howzit -l no-pager -d "Don't paginate output (default on)" 29 | complete -c howzit -l show-code -d "Display the content of fenced run blocks" 30 | complete -c howzit -s t -l title -d "Output title with build notes" 31 | complete -c howzit -x -s w -l wrap -d "Wrap to specified width (default 80, 0 to disable)" 32 | complete -c howzit -s d -l debug -d "Show debug messages (and all messages)" 33 | complete -c howzit -s q -l quiet -d "Silence info message" 34 | complete -c howzit -l verbose -d "Show all messages" 35 | complete -c howzit -s h -l help -d "Display this screen" 36 | complete -c howzit -s v -l version -d "Display version number" 37 | -------------------------------------------------------------------------------- /spec/topic_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Howzit::Topic do 6 | title = 'Test Title' 7 | content = 'Test Content' 8 | subject(:topic) { Howzit::Topic.new(title, content) } 9 | 10 | describe '.new' do 11 | it 'makes a new topic instance' do 12 | expect(topic).to be_a described_class 13 | end 14 | it 'has the correct title' do 15 | expect(topic.title).to eq title 16 | end 17 | it 'has the correct content' do 18 | expect(topic.content).to eq content 19 | end 20 | end 21 | end 22 | 23 | describe Howzit::Topic do 24 | subject(:topic) { 25 | bn = Howzit.buildnote 26 | bn.find_topic('Topic Balogna')[0] 27 | } 28 | 29 | describe '.title' do 30 | it 'has the correct title' do 31 | expect(topic.title).to match(/Topic Balogna/) 32 | end 33 | end 34 | 35 | describe '.tasks' do 36 | it 'has 3 tasks' do 37 | expect(topic.tasks.count).to eq 3 38 | end 39 | end 40 | 41 | describe '.prereqs' do 42 | it 'has prereq' do 43 | expect(topic.prereqs.count).to eq 1 44 | end 45 | end 46 | 47 | describe '.postreqs' do 48 | it 'has postreq' do 49 | expect(topic.postreqs.count).to eq 1 50 | end 51 | end 52 | 53 | describe '.grep' do 54 | it 'returns true for matching pattern in content' do 55 | expect(topic.grep('prereq.*?ite')).to be_truthy 56 | end 57 | 58 | it 'returns true for matching pattern in title' do 59 | expect(topic.grep('bal.*?na')).to be_truthy 60 | end 61 | 62 | it 'fails on bad pattern' do 63 | expect(topic.grep('xxx+')).to_not be_truthy 64 | end 65 | end 66 | 67 | describe '.run' do 68 | Howzit.options[:default] = true 69 | 70 | it 'shows prereq and postreq' do 71 | expect { topic.run }.to output(/prerequisite/).to_stdout 72 | expect { topic.run }.to output(/postrequisite/).to_stdout 73 | end 74 | 75 | it 'Copies to clipboard' do 76 | expect { 77 | ENV['RUBYOPT'] = '-W1' 78 | Howzit.options[:log_level] = 0 79 | topic.run 80 | }.to output(/Copied/).to_stderr 81 | end 82 | end 83 | 84 | describe '.print_out' do 85 | before do 86 | Howzit.options[:header_format] = :block 87 | Howzit.options[:color] = false 88 | Howzit.options[:show_all_code] = false 89 | end 90 | 91 | it 'prints the topic title' do 92 | expect(topic.print_out({ single: true, header: true }).join("\n").uncolor).to match(/▌Topic Balogna/) 93 | end 94 | 95 | it 'prints a task title' do 96 | expect(topic.print_out({ single: true, header: true }).join("\n").uncolor).to match(/▶ Null Output/) 97 | end 98 | 99 | it 'prints task action with --show-code' do 100 | Howzit.options[:show_all_code] = true 101 | expect(topic.print_out({ single: true, header: true }).join("\n").uncolor).to match(/▶ ls -1/) 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/howzit/run_report.rb: -------------------------------------------------------------------------------- 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 | output_lines = ["\n\n***\n"] + lines 26 | output_lines.join("\n") 27 | end 28 | 29 | def format_line(entry, prefix_topic) 30 | symbol = entry[:success] ? '✅' : '❌' 31 | parts = ["#{symbol} "] 32 | if prefix_topic && entry[:topic] && !entry[:topic].empty? 33 | # Escape braces in topic name to prevent color code interpretation 34 | topic_escaped = entry[:topic].gsub(/\{/, '\\{').gsub(/\}/, '\\}') 35 | parts << "{bw}#{topic_escaped}{x}: " 36 | end 37 | # Escape braces in task name to prevent color code interpretation 38 | task_escaped = entry[:task].gsub(/\{/, '\\{').gsub(/\}/, '\\}') 39 | parts << "{by}#{task_escaped}{x}" 40 | unless entry[:success] 41 | reason = entry[:exit_status] ? "exit code #{entry[:exit_status]}" : 'failed' 42 | parts << " {br}(#{reason}){x}" 43 | end 44 | parts.join.c 45 | end 46 | 47 | # Table formatting methods kept for possible future use 48 | def format_as_table 49 | return '' if entries.empty? 50 | 51 | rows = entries.map { |entry| format_row(entry, Howzit.multi_topic_run) } 52 | 53 | # Status column width: " :--: " = 6 chars (4 for :--: plus 1 space each side) 54 | # Emoji is 2-width in terminal, so we need 2 spaces on each side to center it 55 | status_width = 6 56 | task_width = [4, rows.map { |r| r[:task_plain].length }.max].max 57 | 58 | # Build the table with emoji header - center emoji in 6-char column 59 | header = "| 🚥 | #{'Task'.ljust(task_width)} |" 60 | separator = "| :--: | #{':' + '-' * (task_width - 1)} |" 61 | 62 | table_lines = [header, separator] 63 | rows.each do |row| 64 | table_lines << table_row_colored(row[:status], row[:task], row[:task_plain], status_width, task_width) 65 | end 66 | 67 | table_lines.join("\n") 68 | end 69 | 70 | def table_row_colored(status, task, task_plain, _status_width, task_width) 71 | task_padding = task_width - task_plain.length 72 | 73 | "| #{status} | #{task}#{' ' * task_padding} |" 74 | end 75 | 76 | def format_row(entry, prefix_topic) 77 | # Use plain emoji without color codes - the emoji itself provides visual meaning 78 | # and complex ANSI codes interfere with mdless table rendering 79 | symbol = entry[:success] ? '✅' : '❌' 80 | 81 | task_parts = [] 82 | task_parts_plain = [] 83 | 84 | if prefix_topic && entry[:topic] && !entry[:topic].empty? 85 | # Escape braces in topic name to prevent color code interpretation 86 | topic_escaped = entry[:topic].gsub(/\{/, '\\{').gsub(/\}/, '\\}') 87 | task_parts << "{bw}#{topic_escaped}{x}: " 88 | task_parts_plain << "#{entry[:topic]}: " 89 | end 90 | 91 | # Escape braces in task name to prevent color code interpretation 92 | task_escaped = entry[:task].gsub(/\{/, '\\{').gsub(/\}/, '\\}') 93 | task_parts << "{by}#{task_escaped}{x}" 94 | task_parts_plain << entry[:task] 95 | 96 | unless entry[:success] 97 | reason = entry[:exit_status] ? "exit code #{entry[:exit_status]}" : 'failed' 98 | task_parts << " {br}(#{reason}){x}" 99 | task_parts_plain << " (#{reason})" 100 | end 101 | 102 | { 103 | status: symbol, 104 | status_plain: symbol, 105 | task: task_parts.join.c, 106 | task_plain: task_parts_plain.join 107 | } 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # require 'bump/tasks' 4 | require 'bundler/gem_tasks' 5 | require 'rspec/core/rake_task' 6 | require 'rubocop/rake_task' 7 | require 'yard' 8 | require 'tty-spinner' 9 | require 'rdoc/task' 10 | 11 | module TempFixForRakeLastComment 12 | def last_comment 13 | last_description 14 | end 15 | end 16 | Rake::Application.send :include, TempFixForRakeLastComment 17 | 18 | Rake::RDocTask.new do |rd| 19 | rd.main = 'README.rdoc' 20 | rd.rdoc_files.include('README.rdoc', 'lib/**/*.rb', 'bin/**/*') 21 | rd.title = 'Howzit' 22 | end 23 | 24 | task default: %i[test yard] 25 | 26 | desc 'Run test suite' 27 | task test: %i[rubocop spec] 28 | 29 | RSpec::Core::RakeTask.new 30 | 31 | RuboCop::RakeTask.new do |t| 32 | t.formatters = ['progress'] 33 | end 34 | 35 | YARD::Rake::YardocTask.new 36 | 37 | desc 'Development version check' 38 | task :ver do 39 | gver = `git ver` 40 | cver = IO.read(File.join(File.dirname(__FILE__), 'CHANGELOG.md')).match(/^#+ (\d+\.\d+\.\d+(\w+)?)/)[1] 41 | res = `grep VERSION lib/howzit/version.rb` 42 | version = res.match(/VERSION *= *['"](\d+\.\d+\.\d+(\w+)?)/)[1] 43 | puts "git tag: #{gver}" 44 | puts "version.rb: #{version}" 45 | puts "changelog: #{cver}" 46 | end 47 | 48 | desc 'Changelog version check' 49 | task :cver do 50 | puts IO.read(File.join(File.dirname(__FILE__), 'CHANGELOG.md')).match(/^#+ (\d+\.\d+\.\d+(\w+)?)/)[1] 51 | end 52 | 53 | desc 'Run tests in Docker' 54 | task :dockertest, :version, :login do |_, args| 55 | args.with_defaults(version: 'all', login: false) 56 | case args[:version] 57 | when /^a/ 58 | %w[2 3 32].each do |v| 59 | Rake::Task['dockertest'].reenable 60 | Rake::Task['dockertest'].invoke(v, false) 61 | end 62 | Process.exit 0 63 | when /^32/ 64 | img = 'howzittest32' 65 | file = 'docker/Dockerfile-3.2' 66 | when /^3/ 67 | img = 'howzittest3' 68 | file = 'docker/Dockerfile-3.0' 69 | # when /6$/ 70 | # img = 'howzittest26' 71 | # file = 'docker/Dockerfile-2.6' 72 | when /(^2|7$)/ 73 | img = 'howzittest27' 74 | file = 'docker/Dockerfile-2.7' 75 | else 76 | img = 'howzittest' 77 | file = 'docker/Dockerfile' 78 | end 79 | 80 | d_spinner = TTY::Spinner.new("[:spinner] Setting up Docker", hide_cursor: true, format: :dots) 81 | d_spinner.auto_spin 82 | `docker build . --file #{file} -t #{img} &> /dev/null` 83 | d_spinner.success 84 | d_spinner.stop 85 | 86 | exec "docker run -v #{File.dirname(__FILE__)}:/howzit -it #{img} /bin/bash -l" if args[:login] 87 | 88 | spinner = TTY::Spinner.new("[:spinner] Running tests #{img}", hide_cursor: true, format: :dots) 89 | 90 | spinner.auto_spin 91 | res = `docker run --rm -v #{File.dirname(__FILE__)}:/howzit -it #{img}` 92 | commit = `bash -c "docker commit $(docker ps -a|grep #{img}|awk '{print $1}'|head -n 1) #{img}"`.strip 93 | if $?.exitstatus == 0 94 | spinner.success 95 | else 96 | spinner.error 97 | puts res 98 | end 99 | spinner.stop 100 | 101 | puts commit&.empty? ? "Error commiting Docker tag #{img}" : "Committed Docker tag #{img}" 102 | end 103 | 104 | desc 'Alias for build' 105 | task package: :build 106 | 107 | desc 'Bump incremental version number' 108 | task :bump, :type do |_, args| 109 | args.with_defaults(type: 'inc') 110 | version_file = 'lib/howzit/version.rb' 111 | content = IO.read(version_file) 112 | content.sub!(/VERSION = '(?\d+)\.(?\d+)\.(?\d+)(?
\S+)?'/) do
113 |     m = Regexp.last_match
114 |     major = m['major'].to_i
115 |     minor = m['minor'].to_i
116 |     inc = m['inc'].to_i
117 |     pre = m['pre']
118 | 
119 |     case args[:type]
120 |     when /^maj/
121 |       major += 1
122 |       minor = 0
123 |       inc = 0
124 |     when /^min/
125 |       minor += 1
126 |       inc = 0
127 |     else
128 |       inc += 1
129 |     end
130 | 
131 |     $stdout.puts "At version #{major}.#{minor}.#{inc}#{pre}"
132 |     "VERSION = '#{major}.#{minor}.#{inc}#{pre}'"
133 |   end
134 |   File.open(version_file, 'w+') { |f| f.puts content }
135 | end
136 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
  1 | 
  2 | # Howzit
  3 | 
  4 | [![Gem](https://img.shields.io/gem/v/howzit.svg)](https://rubygems.org/gems/howzit)
  5 | [![GitHub license](https://img.shields.io/github/license/ttscoff/howzit.svg)](./LICENSE.txt)
  6 | 
  7 | A command-line reference tool for tracking project build systems
  8 | 
  9 | Howzit is a tool that allows you to keep Markdown-formatted notes about a project's tools and procedures. It functions as an easy lookup for notes about a particular task, as well as a task runner to automatically execute appropriate commands.
 10 | 
 11 | 
 12 | ## Features
 13 | 
 14 | - Match topic titles with any portion of title
 15 | - Automatic pagination of output, with optional Markdown highlighting
 16 | - Use `@run()`, `@copy()`, and `@open()` to perform actions within a build notes file
 17 | - Use `@include()` to import another topic's tasks
 18 | - Use fenced code blocks to include/run embedded scripts
 19 | - Sets iTerm 2 marks on topic titles for navigation when paging is disabled
 20 | - Inside of git repositories, howzit will work from subdirectories, assuming build notes are in top level of repo
 21 | - Templates for easily including repeat tasks
 22 | - Grep topics for pattern and choose from matches
 23 | - Use positional and named variables when executing tasks
 24 | 
 25 | ## Getting Started
 26 | 
 27 | ### Prerequisites
 28 | 
 29 | - Ruby 2.4+ (It probably works on older Rubys, but is untested prior to 2.4.1.)
 30 | - Optional: if [`fzf`](https://github.com/junegunn/fzf) is available, it will be used for handling multiple choice selections
 31 | - Optional: if [`bat`](https://github.com/sharkdp/bat) is available it will page with that
 32 | - Optional: [`mdless`](https://github.com/ttscoff/mdless) or [`mdcat`](https://github.com/lunaryorn/mdcat) for formatting output
 33 | 
 34 | ### Installing
 35 | 
 36 | You can install `howzit` by running:
 37 | 
 38 |     gem install howzit
 39 | 
 40 | If you run into permission errors using the above command, you'll need to use `gem install --user-install howzit`. If that fails, either use `sudo` (`sudo gem install howzit`) or if you're using Homebrew, you have the option to install via [brew-gem](https://github.com/sportngin/brew-gem):
 41 | 
 42 |     brew install brew-gem
 43 |     brew gem install howzit
 44 | 
 45 | ### Usage
 46 | 
 47 | [See the wiki](https://github.com/ttscoff/howzit/wiki) for documentation.
 48 | 
 49 | ## Author
 50 | 
 51 | **Brett Terpstra** - [brettterpstra.com](https://brettterpstra.com)
 52 | 
 53 | ## License
 54 | 
 55 | This project is licensed under the MIT License - see the [LICENSE.txt](LICENSE.txt) file for details.
 56 | 
 57 | ## Warranty
 58 | 
 59 | This software is provided "as is" and without any express or
 60 | implied warranties, including, without limitation, the implied
 61 | warranties of merchantibility and fitness for a particular
 62 | purpose.
 63 | 
 64 | ## Documentation
 65 | 
 66 | - [Howzit Wiki][Wiki].
 67 | - [YARD documentation][RubyDoc] is hosted by RubyDoc.info.
 68 | - [Interactive documentation][Omniref] is hosted by Omniref.
 69 | 
 70 | [Wiki]: https://github.com/ttscoff/howzit/wiki
 71 | [RubyDoc]: http://www.rubydoc.info/gems/howzit
 72 | [Omniref]: https://www.omniref.com/ruby/gems/howzit
 73 | 
 74 | 
 75 | ## Development and Testing
 76 | 
 77 | ### Source Code
 78 | 
 79 | The [howzit source] is hosted on GitHub.
 80 | Clone the project with
 81 | 
 82 | ```
 83 | $ git clone https://github.com/ttscoff/howzit.git
 84 | ```
 85 | 
 86 | [howzit source]: https://github.com/ttscoff/howzit
 87 | 
 88 | ### Requirements
 89 | 
 90 | You will need [Ruby] with [Bundler].
 91 | 
 92 | Install the development dependencies with
 93 | 
 94 | ```
 95 | $ bundle
 96 | ```
 97 | 
 98 | [Bundler]: http://bundler.io/
 99 | [Ruby]: https://www.ruby-lang.org/
100 | 
101 | ### Rake
102 | 
103 | Run `$ rake -T` to see all Rake tasks.
104 | 
105 | ```
106 | rake build                 # Build howzit-2.0.1.gem into the pkg directory
107 | rake bump:current[tag]     # Show current gem version
108 | rake bump:major[tag]       # Bump major part of gem version
109 | rake bump:minor[tag]       # Bump minor part of gem version
110 | rake bump:patch[tag]       # Bump patch part of gem version
111 | rake bump:pre[tag]         # Bump pre part of gem version
112 | rake bump:set              # Sets the version number using the VERSION environment variable
113 | rake clean                 # Remove any temporary products
114 | rake clobber               # Remove any generated files
115 | rake install               # Build and install howzit-2.0.1.gem into system gems
116 | rake install:local         # Build and install howzit-2.0.1.gem into system gems without network access
117 | rake release[remote]       # Create tag v2.0.1 and build and push howzit-2.0.1.gem to Rubygems
118 | rake rubocop               # Run RuboCop
119 | rake rubocop:auto_correct  # Auto-correct RuboCop offenses
120 | rake spec                  # Run RSpec code examples
121 | rake test                  # Run test suite
122 | rake yard                  # Generate YARD Documentation
123 | ```
124 | 
125 | ### Guard
126 | 
127 | Guard tasks have been separated into the following groups:
128 | `doc`, `lint`, and `unit`.
129 | By default, `$ guard` will generate documentation, lint, and run unit tests.
130 | 
131 | ## Contributing
132 | 
133 | Please submit and comment on bug reports and feature requests.
134 | 
135 | To submit a patch:
136 | 
137 | 1. Fork it (https://github.com/ttscoff/howzit/fork).
138 | 2. Create your feature branch (`git checkout -b my-new-feature`).
139 | 3. Make changes. Write and run tests.
140 | 4. Commit your changes (`git commit -am 'Add some feature'`).
141 | 5. Push to the branch (`git push origin my-new-feature`).
142 | 6. Create a new Pull Request.
143 | 
144 | 
145 | 


--------------------------------------------------------------------------------
/src/_README.md:
--------------------------------------------------------------------------------
  1 | 
  2 | # Howzit
  3 | 
  4 | [![Gem](https://img.shields.io/gem/v/howzit.svg)](https://rubygems.org/gems/howzit)
  5 | [![GitHub license](https://img.shields.io/github/license/ttscoff/howzit.svg)](./LICENSE.txt)
  6 | 
  7 | A command-line reference tool for tracking project build systems
  8 | 
  9 | Howzit is a tool that allows you to keep Markdown-formatted notes about a project's tools and procedures. It functions as an easy lookup for notes about a particular task, as well as a task runner to automatically execute appropriate commands.
 10 | 
 11 | 
 12 | ## Features
 13 | 
 14 | - Match topic titles with any portion of title
 15 | - Automatic pagination of output, with optional Markdown highlighting
 16 | - Use `@run()`, `@copy()`, and `@open()` to perform actions within a build notes file
 17 | - Use `@include()` to import another topic's tasks
 18 | - Use fenced code blocks to include/run embedded scripts
 19 | - Sets iTerm 2 marks on topic titles for navigation when paging is disabled
 20 | - Inside of git repositories, howzit will work from subdirectories, assuming build notes are in top level of repo
 21 | - Templates for easily including repeat tasks
 22 | - Grep topics for pattern and choose from matches
 23 | - Use positional and named variables when executing tasks
 24 | 
 25 | ## Getting Started
 26 | 
 27 | ### Prerequisites
 28 | 
 29 | - Ruby 2.4+ (It probably works on older Rubys, but is untested prior to 2.4.1.)
 30 | - Optional: if [`fzf`](https://github.com/junegunn/fzf) is available, it will be used for handling multiple choice selections
 31 | - Optional: if [`bat`](https://github.com/sharkdp/bat) is available it will page with that
 32 | - Optional: [`mdless`](https://github.com/ttscoff/mdless) or [`mdcat`](https://github.com/lunaryorn/mdcat) for formatting output
 33 | 
 34 | ### Installing
 35 | 
 36 | You can install `howzit` by running:
 37 | 
 38 |     gem install howzit
 39 | 
 40 | If you run into permission errors using the above command, you'll need to use `gem install --user-install howzit`. If that fails, either use `sudo` (`sudo gem install howzit`) or if you're using Homebrew, you have the option to install via [brew-gem](https://github.com/sportngin/brew-gem):
 41 | 
 42 |     brew install brew-gem
 43 |     brew gem install howzit
 44 | 
 45 | ### Usage
 46 | 
 47 | [See the wiki](https://github.com/ttscoff/howzit/wiki) for documentation.
 48 | 
 49 | ## Author
 50 | 
 51 | **Brett Terpstra** - [brettterpstra.com](https://brettterpstra.com)
 52 | 
 53 | ## License
 54 | 
 55 | This project is licensed under the MIT License - see the [LICENSE.txt](LICENSE.txt) file for details.
 56 | 
 57 | ## Warranty
 58 | 
 59 | This software is provided "as is" and without any express or
 60 | implied warranties, including, without limitation, the implied
 61 | warranties of merchantibility and fitness for a particular
 62 | purpose.
 63 | 
 64 | ## Documentation
 65 | 
 66 | - [Howzit Wiki][Wiki].
 67 | - [YARD documentation][RubyDoc] is hosted by RubyDoc.info.
 68 | - [Interactive documentation][Omniref] is hosted by Omniref.
 69 | 
 70 | [Wiki]: https://github.com/ttscoff/howzit/wiki
 71 | [RubyDoc]: http://www.rubydoc.info/gems/howzit
 72 | [Omniref]: https://www.omniref.com/ruby/gems/howzit
 73 | 
 74 | 
 75 | ## Development and Testing
 76 | 
 77 | ### Source Code
 78 | 
 79 | The [howzit source] is hosted on GitHub.
 80 | Clone the project with
 81 | 
 82 | ```
 83 | $ git clone https://github.com/ttscoff/howzit.git
 84 | ```
 85 | 
 86 | [howzit source]: https://github.com/ttscoff/howzit
 87 | 
 88 | ### Requirements
 89 | 
 90 | You will need [Ruby] with [Bundler].
 91 | 
 92 | Install the development dependencies with
 93 | 
 94 | ```
 95 | $ bundle
 96 | ```
 97 | 
 98 | [Bundler]: http://bundler.io/
 99 | [Ruby]: https://www.ruby-lang.org/
100 | 
101 | ### Rake
102 | 
103 | Run `$ rake -T` to see all Rake tasks.
104 | 
105 | ```
106 | rake build                 # Build howzit-2.0.1.gem into the pkg directory
107 | rake bump:current[tag]     # Show current gem version
108 | rake bump:major[tag]       # Bump major part of gem version
109 | rake bump:minor[tag]       # Bump minor part of gem version
110 | rake bump:patch[tag]       # Bump patch part of gem version
111 | rake bump:pre[tag]         # Bump pre part of gem version
112 | rake bump:set              # Sets the version number using the VERSION environment variable
113 | rake clean                 # Remove any temporary products
114 | rake clobber               # Remove any generated files
115 | rake install               # Build and install howzit-2.0.1.gem into system gems
116 | rake install:local         # Build and install howzit-2.0.1.gem into system gems without network access
117 | rake release[remote]       # Create tag v2.0.1 and build and push howzit-2.0.1.gem to Rubygems
118 | rake rubocop               # Run RuboCop
119 | rake rubocop:auto_correct  # Auto-correct RuboCop offenses
120 | rake spec                  # Run RSpec code examples
121 | rake test                  # Run test suite
122 | rake yard                  # Generate YARD Documentation
123 | ```
124 | 
125 | ### Guard
126 | 
127 | Guard tasks have been separated into the following groups:
128 | `doc`, `lint`, and `unit`.
129 | By default, `$ guard` will generate documentation, lint, and run unit tests.
130 | 
131 | ## Contributing
132 | 
133 | Please submit and comment on bug reports and feature requests.
134 | 
135 | To submit a patch:
136 | 
137 | 1. Fork it (https://github.com/ttscoff/howzit/fork).
138 | 2. Create your feature branch (`git checkout -b my-new-feature`).
139 | 3. Make changes. Write and run tests.
140 | 4. Commit your changes (`git commit -am 'Add some feature'`).
141 | 5. Push to the branch (`git push origin my-new-feature`).
142 | 6. Create a new Pull Request.
143 | 
144 | 
145 | 
146 | 


--------------------------------------------------------------------------------
/lib/howzit/task.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | require 'English'
  4 | 
  5 | module Howzit
  6 |   # Task object
  7 |   class Task
  8 |     attr_reader :type, :title, :action, :arguments, :parent, :optional, :default, :last_status
  9 | 
 10 |     ##
 11 |     ## Initialize a Task object
 12 |     ##
 13 |     ## @param      attributes  [Hash] the task attributes
 14 |     ## @param      optional    [Boolean] Task requires
 15 |     ##                         confirmation
 16 |     ## @param      default     [Boolean] Default response
 17 |     ##                         for confirmation dialog
 18 |     ##
 19 |     ## @option attributes :type [Symbol] task type (:block, :run, :include, :copy)
 20 |     ## @option attributes :title [String] task title
 21 |     ## @option attributes :action [String] task action
 22 |     ## @option attributes :parent [String] title of nested (included) topic origin
 23 |     def initialize(attributes, optional: false, default: true)
 24 |       @prefix = "{bw}\u{25B7}\u{25B7} {x}"
 25 |       # arrow = "{bw}\u{279F}{x}"
 26 |       @arguments = attributes[:arguments] || []
 27 | 
 28 |       @type = attributes[:type] || :run
 29 |       @title = attributes[:title].nil? ? nil : attributes[:title].to_s
 30 |       @parent = attributes[:parent] || nil
 31 | 
 32 |       @action = attributes[:action].render_arguments || nil
 33 | 
 34 |       @optional = optional
 35 |       @default = default
 36 |       @last_status = nil
 37 |     end
 38 | 
 39 |     ##
 40 |     ## Inspect
 41 |     ##
 42 |     ## @return     [String] description
 43 |     ##
 44 |     def inspect
 45 |       %(<#Howzit::Task @type=:#{@type} @title="#{@title}" @action="#{@action}" @arguments=#{@arguments} @block?=#{@action.split(/\n/).count > 1}>)
 46 |     end
 47 | 
 48 |     ##
 49 |     ## Output string representation
 50 |     ##
 51 |     ## @return     [String] string representation of the object.
 52 |     ##
 53 |     def to_s
 54 |       @title
 55 |     end
 56 | 
 57 |     ##
 58 |     ## Execute a block type
 59 |     ##
 60 |     def run_block
 61 |       Howzit.console.info "#{@prefix}{bg}Running block {bw}#{@title}{x}".c if Howzit.options[:log_level] < 2
 62 |       block = @action
 63 |       script = Tempfile.new('howzit_script')
 64 |       begin
 65 |         script.write(block)
 66 |         script.close
 67 |         File.chmod(0o777, script.path)
 68 |         res = system(%(/bin/sh -c "#{script.path}"))
 69 |       ensure
 70 |         script.close
 71 |         script.unlink
 72 |       end
 73 | 
 74 |       update_last_status(res ? 0 : 1)
 75 |       res
 76 |     end
 77 | 
 78 |     ##
 79 |     ## Execute an include task
 80 |     ##
 81 |     ## @return     [Array] [[Array] output, [Integer] number of tasks executed]
 82 |     ##
 83 |     def run_include
 84 |       output = []
 85 |       action = @action
 86 | 
 87 |       matches = Howzit.buildnote.find_topic(action)
 88 |       raise "Topic not found: #{action}" if matches.empty?
 89 | 
 90 |       Howzit.console.info("#{@prefix}{by}Running tasks from {bw}#{matches[0].title}{x}".c)
 91 |       output.concat(matches[0].run(nested: true))
 92 |       Howzit.console.info("{by}End include: #{matches[0].tasks.count} tasks{x}".c)
 93 |       @last_status = nil
 94 |       [output, matches[0].tasks.count]
 95 |     end
 96 | 
 97 |     ##
 98 |     ## Execute a run task
 99 |     ##
100 |     def run_run
101 |       # If a title was explicitly provided (different from action), always use it
102 |       # Otherwise, use action (or respect show_all_code if no title)
103 |       display_title = if @title && !@title.empty? && @title != @action
104 |                         # Title was explicitly provided, use it
105 |                         @title
106 |                       elsif Howzit.options[:show_all_code]
107 |                         # No explicit title, show code if requested
108 |                         @action
109 |                       else
110 |                         # No explicit title, use title if available (might be same as action), otherwise action
111 |                         @title && !@title.empty? ? @title : @action
112 |                       end
113 |       Howzit.console.info("#{@prefix}{bg}Running {bw}#{display_title}{x}".c)
114 |       ENV['HOWZIT_SCRIPTS'] = File.expand_path('~/.config/howzit/scripts')
115 |       res = system(@action)
116 |       update_last_status(res ? 0 : 1)
117 |       res
118 |     end
119 | 
120 |     ##
121 |     ## Execute a copy task
122 |     ##
123 |     def run_copy
124 |       # If a title was explicitly provided (different from action), always use it
125 |       # Otherwise, use action (or respect show_all_code if no title)
126 |       display_title = if @title && !@title.empty? && @title != @action
127 |                         # Title was explicitly provided, use it
128 |                         @title
129 |                       elsif Howzit.options[:show_all_code]
130 |                         # No explicit title, show code if requested
131 |                         @action
132 |                       else
133 |                         # No explicit title, use title if available (might be same as action), otherwise action
134 |                         @title && !@title.empty? ? @title : @action
135 |                       end
136 |       Howzit.console.info("#{@prefix}{bg}Copied {bw}#{display_title}{bg} to clipboard{x}".c)
137 |       Util.os_copy(@action)
138 |       @last_status = 0
139 |       true
140 |     end
141 | 
142 |     ##
143 |     ## Execute the task
144 |     ##
145 |     def run
146 |       output = []
147 |       tasks = 1
148 |       res = if @type == :block
149 |               run_block
150 |             else
151 |               case @type
152 |               when :include
153 |                 output, tasks = run_include
154 |               when :run
155 |                 run_run
156 |               when :copy
157 |                 run_copy
158 |               when :open
159 |                 Util.os_open(@action)
160 |                 @last_status = 0
161 |                 true
162 |               end
163 |             end
164 | 
165 |       [output, tasks, res]
166 |     end
167 | 
168 |     def update_last_status(default = nil)
169 |       status = if defined?($CHILD_STATUS) && $CHILD_STATUS
170 |                  $CHILD_STATUS.exitstatus
171 |                else
172 |                  default
173 |                end
174 |       @last_status = status
175 |     end
176 | 
177 |     ##
178 |     ## Output terminal-formatted list item
179 |     ##
180 |     ## @return     [String] List representation of the object.
181 |     ##
182 |     def to_list
183 |       "    * #{@type}: #{@title.preserve_escapes}"
184 |     end
185 |   end
186 | end
187 | 


--------------------------------------------------------------------------------
/spec/buildnote_spec.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | require 'spec_helper'
  4 | 
  5 | describe Howzit::BuildNote do
  6 |   subject(:how) { Howzit.buildnote('builda.md') }
  7 | 
  8 |   describe ".note_file" do
  9 |     it "locates a build note file" do
 10 |       expect(how.note_file).not_to be_empty
 11 |       expect(how.note_file).to match /builda.md$/
 12 |     end
 13 |   end
 14 | 
 15 |   describe ".grep" do
 16 |     it "finds topic containing 'bermuda'" do
 17 |       expect(how.grep('bermuda').map { |topic| topic.title }).to include('Topic Tropic')
 18 |     end
 19 |     it "does not return non-matching topic" do
 20 |       expect(how.grep('bermuda').map { |topic| topic.title }).not_to include('Topic Balogna')
 21 |     end
 22 |   end
 23 | 
 24 |   describe ".find_topic" do
 25 |     it "finds the Topic Tropic topic" do
 26 |       matches = how.find_topic('tropic')
 27 |       expect(matches.count).to eq 1
 28 |       expect(matches[0].title).to eq 'Topic Tropic'
 29 |     end
 30 | 
 31 |     it "fuzzy matches" do
 32 |       Howzit.options[:matching] = 'fuzzy'
 33 |       matches = how.find_topic('trpc')
 34 |       expect(matches.count).to eq 1
 35 |       expect(matches[0].title).to eq 'Topic Tropic'
 36 |     end
 37 | 
 38 |     it "succeeds with partial match" do
 39 |       Howzit.options[:matching] = 'partial'
 40 |       matches = how.find_topic('trop')
 41 |       expect(matches.count).to eq 1
 42 |       expect(matches[0].title).to eq 'Topic Tropic'
 43 |     end
 44 | 
 45 |     it "succeeds with beginswith match" do
 46 |       Howzit.options[:matching] = 'beginswith'
 47 |       matches = how.find_topic('topic')
 48 |       expect(matches.count).to eq 3
 49 |       expect(matches[0].title).to eq 'Topic Balogna'
 50 |     end
 51 | 
 52 |     it "succeeds with exact match" do
 53 |       Howzit.options[:matching] = 'exact'
 54 |       matches = how.find_topic('topic tropic')
 55 |       expect(matches.count).to eq 1
 56 |       expect(matches[0].title).to eq 'Topic Tropic'
 57 |     end
 58 | 
 59 |     it "fails with incomplete exact match" do
 60 |       Howzit.options[:matching] = 'exact'
 61 |       matches = how.find_topic('topic trop')
 62 |       expect(matches.count).to eq 0
 63 |     end
 64 | 
 65 |     it "matches topics containing colon even without space" do
 66 |       matches = how.find_topic('git:clean')
 67 |       expect(matches.count).to eq 1
 68 |       expect(matches[0].title).to eq 'Git: Clean Repo'
 69 |     end
 70 | 
 71 |     it "Handles multiple matches with best match" do
 72 |       Howzit.options[:matching] = 'fuzzy'
 73 |       Howzit.options[:multiple_matches] = :best
 74 |       matches = how.find_topic('banana')
 75 |       expect(matches.first.title).to match(/banana/i)
 76 |     end
 77 |   end
 78 | 
 79 |   describe ".find_topic_exact" do
 80 |     it "finds exact whole-word match" do
 81 |       matches = how.find_topic_exact('Topic Tropic')
 82 |       expect(matches.count).to eq 1
 83 |       expect(matches[0].title).to eq 'Topic Tropic'
 84 |     end
 85 | 
 86 |     it "finds exact match case-insensitively" do
 87 |       matches = how.find_topic_exact('topic tropic')
 88 |       expect(matches.count).to eq 1
 89 |       expect(matches[0].title).to eq 'Topic Tropic'
 90 |     end
 91 | 
 92 |     it "does not match partial phrases" do
 93 |       matches = how.find_topic_exact('topic trop')
 94 |       expect(matches.count).to eq 0
 95 |     end
 96 | 
 97 |     it "does not match single word when phrase has multiple words" do
 98 |       matches = how.find_topic_exact('topic')
 99 |       expect(matches.count).to eq 0
100 |     end
101 | 
102 |     it "matches single-word topics" do
103 |       matches = how.find_topic_exact('Happy Bgagngagnga')
104 |       expect(matches.count).to eq 1
105 |       expect(matches[0].title).to eq 'Happy Bgagngagnga'
106 |     end
107 | 
108 |     it "matches topics with colons" do
109 |       matches = how.find_topic_exact('Git: Clean Repo')
110 |       expect(matches.count).to eq 1
111 |       expect(matches[0].title).to eq 'Git: Clean Repo'
112 |     end
113 |   end
114 | 
115 |   describe ".topics" do
116 |     it "contains 7 topics" do
117 |       expect(how.list_topics.count).to eq 7
118 |     end
119 |     it "outputs a newline-separated string for completion" do
120 |       expect(how.list_completions.scan(/\n/).count).to eq 6
121 |     end
122 |   end
123 | 
124 |   describe "#topic_search_terms_from_cli" do
125 |     after { Howzit.cli_args = [] }
126 | 
127 |     it "respects separators found inside topics" do
128 |       Howzit.cli_args = ['git:clean:blog:update post']
129 |       expect(how.send(:topic_search_terms_from_cli)).to eq(['git:clean', 'blog:update post'])
130 |     end
131 | 
132 |     it "keeps comma inside matching topics" do
133 |       Howzit.cli_args = ['release, deploy,topic balogna']
134 |       expect(how.send(:topic_search_terms_from_cli)).to eq(['release, deploy', 'topic balogna'])
135 |     end
136 |   end
137 | 
138 |   describe "#collect_topic_matches" do
139 |     before do
140 |       Howzit.options[:multiple_matches] = :first
141 |     end
142 | 
143 |     it "collects matches for multiple search terms" do
144 |       search_terms = ['topic tropic', 'topic banana']
145 |       output = []
146 |       matches = how.send(:collect_topic_matches, search_terms, output)
147 |       expect(matches.count).to eq 2
148 |       expect(matches.map(&:title)).to include('Topic Tropic', 'Topic Banana')
149 |     end
150 | 
151 |     it "prefers exact matches over fuzzy matches" do
152 |       # 'Topic Banana' should exact-match, not fuzzy match to multiple
153 |       search_terms = ['topic banana']
154 |       output = []
155 |       matches = how.send(:collect_topic_matches, search_terms, output)
156 |       expect(matches.count).to eq 1
157 |       expect(matches[0].title).to eq 'Topic Banana'
158 |     end
159 | 
160 |     it "falls back to fuzzy match when no exact match" do
161 |       Howzit.options[:matching] = 'fuzzy'
162 |       search_terms = ['trpc']  # fuzzy for 'tropic'
163 |       output = []
164 |       matches = how.send(:collect_topic_matches, search_terms, output)
165 |       expect(matches.count).to eq 1
166 |       expect(matches[0].title).to eq 'Topic Tropic'
167 |     end
168 | 
169 |     it "adds error message for unmatched terms" do
170 |       search_terms = ['nonexistent topic xyz']
171 |       output = []
172 |       matches = how.send(:collect_topic_matches, search_terms, output)
173 |       expect(matches.count).to eq 0
174 |       expect(output.join).to match(/no topic match found/i)
175 |     end
176 | 
177 |     it "collects multiple topics from comma-separated input" do
178 |       Howzit.cli_args = ['topic tropic,topic banana']
179 |       search_terms = how.send(:topic_search_terms_from_cli)
180 |       output = []
181 |       matches = how.send(:collect_topic_matches, search_terms, output)
182 |       expect(matches.count).to eq 2
183 |       Howzit.cli_args = []
184 |     end
185 |   end
186 | 
187 |   describe "#smart_split_topics" do
188 |     it "splits on comma when not part of topic title" do
189 |       result = how.send(:smart_split_topics, 'topic tropic,topic banana')
190 |       expect(result).to eq(['topic tropic', 'topic banana'])
191 |     end
192 | 
193 |     it "preserves comma when part of topic title" do
194 |       result = how.send(:smart_split_topics, 'release, deploy,topic banana')
195 |       expect(result).to eq(['release, deploy', 'topic banana'])
196 |     end
197 | 
198 |     it "preserves colon when part of topic title" do
199 |       result = how.send(:smart_split_topics, 'git:clean,blog:update post')
200 |       expect(result).to eq(['git:clean', 'blog:update post'])
201 |     end
202 | 
203 |     it "handles mixed separators correctly" do
204 |       result = how.send(:smart_split_topics, 'git:clean:topic tropic')
205 |       expect(result).to eq(['git:clean', 'topic tropic'])
206 |     end
207 |   end
208 | 
209 |   describe "#parse_template_required_vars" do
210 |     let(:template_with_required) do
211 |       Tempfile.new(['template', '.md']).tap do |f|
212 |         f.write("required: repo_url, author\n\n# Template\n\n## Section")
213 |         f.close
214 |       end
215 |     end
216 | 
217 |     let(:template_without_required) do
218 |       Tempfile.new(['template', '.md']).tap do |f|
219 |         f.write("# Template\n\n## Section")
220 |         f.close
221 |       end
222 |     end
223 | 
224 |     after do
225 |       template_with_required.unlink
226 |       template_without_required.unlink
227 |     end
228 | 
229 |     it "parses required variables from template metadata" do
230 |       vars = how.send(:parse_template_required_vars, template_with_required.path)
231 |       expect(vars).to eq(['repo_url', 'author'])
232 |     end
233 | 
234 |     it "returns empty array when no required metadata" do
235 |       vars = how.send(:parse_template_required_vars, template_without_required.path)
236 |       expect(vars).to eq([])
237 |     end
238 |   end
239 | end
240 | 


--------------------------------------------------------------------------------
/lib/howzit/config.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | module Howzit
  4 |   # Config Class
  5 |   class Config
  6 |     attr_reader :options
  7 | 
  8 |     # Configuration defaults
  9 |     DEFAULTS = {
 10 |       color: true,
 11 |       config_editor: ENV['EDITOR'] || nil,
 12 |       editor: ENV['EDITOR'] || nil,
 13 |       header_format: 'border',
 14 |       highlight: true,
 15 |       highlighter: 'auto',
 16 |       include_upstream: false,
 17 |       log_level: 1, # 0: debug, 1: info, 2: warn, 3: error
 18 |       matching: 'partial', # exact, partial, fuzzy, beginswith
 19 |       multiple_matches: 'choose',
 20 |       output_title: false,
 21 |       pager: 'auto',
 22 |       paginate: true,
 23 |       show_all_code: false,
 24 |       show_all_on_error: false,
 25 |       wrap: 0
 26 |     }.deep_freeze
 27 | 
 28 |     DEFAULT_COLORS = [
 29 |       [:black,              30],
 30 |       [:red,                31],
 31 |       [:green,              32],
 32 |       [:yellow,             33],
 33 |       [:blue,               34],
 34 |       [:magenta,            35],
 35 |       [:purple,             35],
 36 |       [:cyan,               36],
 37 |       [:white,              37],
 38 |       [:bgblack,            40],
 39 |       [:bgred,              41],
 40 |       [:bggreen,            42],
 41 |       [:bgyellow,           43],
 42 |       [:bgblue,             44],
 43 |       [:bgmagenta,          45],
 44 |       [:bgpurple,           45],
 45 |       [:bgcyan,             46],
 46 |       [:bgwhite,            47],
 47 |       [:boldblack,          90],
 48 |       [:boldred,            91],
 49 |       [:boldgreen,          92],
 50 |       [:boldyellow,         93],
 51 |       [:boldblue,           94],
 52 |       [:boldmagenta,        95],
 53 |       [:boldpurple,         95],
 54 |       [:boldcyan,           96],
 55 |       [:boldwhite,          97],
 56 |       [:boldbgblack,       100],
 57 |       [:boldbgred,         101],
 58 |       [:boldbggreen,       102],
 59 |       [:boldbgyellow,      103],
 60 |       [:boldbgblue,        104],
 61 |       [:boldbgmagenta,     105],
 62 |       [:boldbgpurple,      105],
 63 |       [:boldbgcyan,        106],
 64 |       [:boldbgwhite,       107]
 65 |     ].to_h.deep_freeze
 66 | 
 67 |     ##
 68 |     ## Initialize a config object
 69 |     ##
 70 |     def initialize
 71 |       load_options
 72 |     end
 73 | 
 74 |     ##
 75 |     ## Write a config to a file
 76 |     ##
 77 |     ## @param      config  The configuration
 78 |     ##
 79 |     def write_config(config)
 80 |       File.open(config_file, 'w') { |f| f.puts config.to_yaml }
 81 |     end
 82 | 
 83 |     ##
 84 |     ## Write a theme to a file
 85 |     ##
 86 |     ## @param      config  The configuration
 87 |     ##
 88 |     def write_theme(config)
 89 |       File.open(theme_file, 'w') { |f| f.puts config.to_yaml }
 90 |     end
 91 | 
 92 |     ##
 93 |     ## Test if a file should be ignored based on YAML file
 94 |     ##
 95 |     ## @param      filename  The filename to test
 96 |     ##
 97 |     def should_ignore(filename)
 98 |       return false unless File.exist?(ignore_file)
 99 | 
100 |       @ignore_patterns ||= YAML.load(Util.read_file(ignore_file))
101 | 
102 |       ignore = false
103 | 
104 |       @ignore_patterns.each do |pat|
105 |         if filename =~ /#{pat}/
106 |           ignore = true
107 |           break
108 |         end
109 |       end
110 | 
111 |       ignore
112 |     end
113 | 
114 |     ##
115 |     ## Find the template folder
116 |     ##
117 |     ## @return     [String] path to template folder
118 |     ##
119 |     def template_folder
120 |       File.join(config_dir, 'templates')
121 |     end
122 | 
123 |     ##
124 |     ## Initiate the editor for the config
125 |     ##
126 |     def editor
127 |       edit_config
128 |     end
129 | 
130 |     ## Update editor config
131 |     def update_editor
132 |       puts 'No $EDITOR defined, no value in config'
133 |       editor = Prompt.read_editor
134 |       if editor.nil?
135 |         puts 'Cancelled, no editor stored.'
136 |         Process.exit 1
137 |       end
138 |       update_config_option({ config_editor: editor, editor: editor })
139 |       puts "Default editor set to #{editor}, modify in config file"
140 |       editor
141 |     end
142 | 
143 |     ##
144 |     ## Update a config option and resave config file
145 |     ##
146 |     ## @param      options    [Hash] key value pairs
147 |     ##
148 |     def update_config_option(options)
149 |       options.each do |key, value|
150 |         Howzit.options[key] = value
151 |       end
152 |       write_config(Howzit.options)
153 |     end
154 | 
155 |     private
156 | 
157 |     ##
158 |     ## Load command line options
159 |     ##
160 |     ## @return     [Hash] options with command line flags merged in
161 |     ##
162 |     def load_options
163 |       Color.coloring = $stdout.isatty
164 |       flags = {
165 |         ask: false,
166 |         choose: false,
167 |         default: false,
168 |         for_topic: nil,
169 |         force: false,
170 |         grep: nil,
171 |         list_runnable: false,
172 |         list_runnable_titles: false,
173 |         list_topic_titles: false,
174 |         list_topics: false,
175 |         quiet: false,
176 |         run: false,
177 |         title_only: false,
178 |         verbose: false,
179 |         yes: false
180 |       }
181 | 
182 |       config = load_config
183 |       load_theme
184 |       @options = flags.merge(config)
185 |     end
186 | 
187 |     ##
188 |     ## Get the config directory
189 |     ##
190 |     ## @return     [String] path to config directory
191 |     ##
192 |     def config_dir
193 |       File.expand_path(CONFIG_DIR)
194 |     end
195 | 
196 |     ##
197 |     ## Get the config file
198 |     ##
199 |     ## @return     [String] path to config file
200 |     ##
201 |     def config_file
202 |       File.join(config_dir, CONFIG_FILE)
203 |     end
204 | 
205 |     ##
206 |     ## Get the theme file
207 |     ##
208 |     ## @return     [String] path to config file
209 |     ##
210 |     def theme_file
211 |       File.join(config_dir, COLOR_FILE)
212 |     end
213 | 
214 |     ##
215 |     ## Get the ignore config file
216 |     ##
217 |     ## @return     [String] path to ignore config file
218 |     ##
219 |     def ignore_file
220 |       File.join(config_dir, IGNORE_FILE)
221 |     end
222 | 
223 |     ##
224 |     ## Create a new config file (and directory if needed)
225 |     ##
226 |     ## @param      default     [Hash] default configuration to write
227 |     ##
228 |     def create_config(default)
229 |       unless File.directory?(config_dir)
230 |         Howzit::ConsoleLogger.new(1).info "Creating config directory at #{config_dir}"
231 |         FileUtils.mkdir_p(config_dir)
232 |       end
233 | 
234 |       unless File.exist?(config_file)
235 |         Howzit::ConsoleLogger.new(1).info "Writing fresh config file to #{config_file}"
236 |         write_config(default)
237 |       end
238 |       config_file
239 |     end
240 | 
241 |     ##
242 |     ## Create a new theme file (and directory if needed)
243 |     ##
244 |     ## @param      default     [Hash] default configuration to write
245 |     ##
246 |     def create_theme(default)
247 |       unless File.directory?(config_dir)
248 |         Howzit::ConsoleLogger.new(1).info "Creating theme directory at #{config_dir}"
249 |         FileUtils.mkdir_p(config_dir)
250 |       end
251 | 
252 |       unless File.exist?(theme_file)
253 |         Howzit::ConsoleLogger.new(1).info "Writing fresh theme file to #{theme_file}"
254 |         write_theme(default)
255 |       end
256 |       theme_file
257 |     end
258 | 
259 |     ##
260 |     ## Load the config file
261 |     ##
262 |     ## @return     [Hash] configuration object
263 |     ##
264 |     def load_config
265 |       file = create_config(DEFAULTS)
266 |       config = YAML.load(Util.read_file(file))
267 |       newconfig = config ? DEFAULTS.merge(config) : DEFAULTS
268 |       write_config(newconfig)
269 |       newconfig.dup
270 |     end
271 | 
272 |     ##
273 |     ## Load the theme file
274 |     ##
275 |     ## @return     [Hash] configuration object
276 |     ##
277 |     def load_theme
278 |       file = create_theme(DEFAULT_COLORS)
279 |       config = YAML.load(Util.read_file(file))
280 |       newconfig = config ? DEFAULT_COLORS.merge(config) : DEFAULT_COLORS
281 |       write_theme(newconfig)
282 |       newconfig.dup
283 |     end
284 | 
285 |     ##
286 |     ## Open the config in an editor
287 |     ##
288 |     def edit_config
289 |       editor = Howzit.options.fetch(:config_editor, ENV['EDITOR'])
290 | 
291 |       editor = update_editor if editor.nil?
292 | 
293 |       raise 'No config_editor defined' if editor.nil?
294 | 
295 |       # raise "Invalid editor (#{editor})" unless Util.valid_command?(editor)
296 | 
297 |       load_config
298 |       if Util.valid_command?(editor.split(/ /).first)
299 |         exec %(#{editor} "#{config_file}")
300 |       else
301 |         `open -a "#{editor}" "#{config_file}"`
302 |       end
303 |     end
304 |   end
305 | end
306 | 


--------------------------------------------------------------------------------
/lib/howzit/util.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | module Howzit
  4 |   # Util class
  5 |   module Util
  6 |     class << self
  7 |       ##
  8 |       ## Read a file with UTF-8 encoding and
  9 |       ## leading/trailing whitespace removed
 10 |       ##
 11 |       ## @param      path  [String] The path to read
 12 |       ##
 13 |       ## @return     [String] UTF-8 encoded string
 14 |       ##
 15 |       def read_file(path)
 16 |         IO.read(path).force_encoding('utf-8').strip
 17 |       end
 18 | 
 19 |       ##
 20 |       ## Test if an external command exists and is
 21 |       ## executable. Removes additional arguments and passes
 22 |       ## just the executable to #command_exist?
 23 |       ##
 24 |       ## @param      command  [String] The command
 25 |       ##
 26 |       ## @return     [Boolean] command is valid
 27 |       ##
 28 |       def valid_command?(command)
 29 |         cmd = command.split(' ')[0]
 30 |         command_exist?(cmd)
 31 |       end
 32 | 
 33 |       ##
 34 |       ## Test if external command exists
 35 |       ##
 36 |       ## @param      command  [String] The command
 37 |       ##
 38 |       ## @return     [Boolean] command exists
 39 |       ##
 40 |       def command_exist?(command)
 41 |         exts = ENV.fetch('PATHEXT', '').split(::File::PATH_SEPARATOR)
 42 |         command = File.expand_path(command) if command =~ /^~/
 43 |         if Pathname.new(command).absolute?
 44 |           ::File.exist?(command) || exts.any? { |ext| ::File.exist?("#{command}#{ext}") }
 45 |         else
 46 |           ENV.fetch('PATH', '').split(::File::PATH_SEPARATOR).any? do |dir|
 47 |             file = ::File.join(dir, command)
 48 |             ::File.exist?(file) || exts.any? { |ext| ::File.exist?("#{file}#{ext}") }
 49 |           end
 50 |         end
 51 |       end
 52 | 
 53 |       # If either mdless or mdcat are installed, use that for highlighting
 54 |       # markdown
 55 |       def which_highlighter
 56 |         if Howzit.options[:highlighter] =~ /auto/i
 57 |           highlighters = %w[mdcat mdless]
 58 |           highlighters.delete_if(&:nil?).select!(&:available?)
 59 |           return nil if highlighters.empty?
 60 | 
 61 |           hl = highlighters.first
 62 |           args = case hl
 63 |                  when 'mdless'
 64 |                    '--no-pager'
 65 |                  end
 66 | 
 67 |           [hl, args].join(' ')
 68 |         else
 69 |           hl = Howzit.options[:highlighter].split(/ /)[0]
 70 |           if hl.available?
 71 |             Howzit.options[:highlighter]
 72 |           else
 73 |             Howzit.console.error Color.template("{Rw}Error:{xbw} Specified highlighter (#{Howzit.options[:highlighter]}) not found, switching to auto")
 74 |             Howzit.options[:highlighter] = 'auto'
 75 |             which_highlighter
 76 |           end
 77 |         end
 78 |       end
 79 | 
 80 |       # When pagination is enabled, find the best (in my opinion) option,
 81 |       # favoring environment settings
 82 |       def which_pager
 83 |         if Howzit.options[:pager] =~ /auto/i
 84 |           pagers = [ENV['PAGER'], ENV['GIT_PAGER'],
 85 |                     'bat', 'less', 'more', 'pager']
 86 |           pagers.delete_if(&:nil?).select!(&:available?)
 87 |           return nil if pagers.empty?
 88 | 
 89 |           pg = pagers.first
 90 |           args = case pg
 91 |                  when 'delta'
 92 |                    '--pager="less -FXr"'
 93 |                  when 'less'
 94 |                    '-FXr'
 95 |                  when 'bat'
 96 |                    if Howzit.options[:highlight]
 97 |                      '--language Markdown --style plain --pager="less -FXr"'
 98 |                    else
 99 |                      '--style plain --pager="less -FXr"'
100 |                    end
101 |                  else
102 |                    ''
103 |                  end
104 | 
105 |           [pg, args].join(' ')
106 |         else
107 |           pg = Howzit.options[:pager].split(/ /)[0]
108 |           if pg.available?
109 |             Howzit.options[:pager]
110 |           else
111 |             Howzit.console.error Color.template("{Rw}Error:{xbw} Specified pager (#{Howzit.options[:pager]}) not found, switching to auto")
112 |             Howzit.options[:pager] = 'auto'
113 |             which_pager
114 |           end
115 |         end
116 |       end
117 | 
118 |       # Paginate the output
119 |       def page(text)
120 |         unless $stdout.isatty
121 |           puts text
122 |           return
123 |         end
124 | 
125 |         read_io, write_io = IO.pipe
126 | 
127 |         input = $stdin
128 | 
129 |         pid = Kernel.fork do
130 |           write_io.close
131 |           input.reopen(read_io)
132 |           read_io.close
133 | 
134 |           # Wait until we have input before we start the pager
135 |           IO.select [input]
136 | 
137 |           pager = which_pager
138 | 
139 |           begin
140 |             exec(pager)
141 |           rescue SystemCallError => e
142 |             Howzit.console.error(e)
143 |             exit 1
144 |           end
145 |         end
146 | 
147 |         read_io.close
148 |         begin
149 |           write_io.write(text)
150 |         rescue Errno::EPIPE
151 |           # User quit pager before we finished writing, ignore
152 |         end
153 |         write_io.close
154 | 
155 |         _, status = Process.waitpid2(pid)
156 | 
157 |         status.success?
158 |       end
159 | 
160 |       # print output to terminal
161 |       def show(string, opts = {})
162 |         options = {
163 |           color: true,
164 |           highlight: false,
165 |           paginate: true,
166 |           wrap: 0
167 |         }
168 | 
169 |         options.merge!(opts)
170 | 
171 |         string = string.uncolor unless options[:color]
172 | 
173 |         pipes = ''
174 |         if options[:highlight]
175 |           hl = which_highlighter
176 |           pipes = "|#{hl}" if hl
177 |         end
178 | 
179 |         output = `echo #{Shellwords.escape(string.strip)}#{pipes}`.strip
180 | 
181 |         if options[:paginate] && Howzit.options[:paginate]
182 |           page(output)
183 |         else
184 |           puts output
185 |         end
186 |       end
187 | 
188 |       ##
189 |       ## Platform-agnostic copy-to-clipboard
190 |       ##
191 |       ## @param      string  [String] The string to copy
192 |       ##
193 |       def os_copy(string)
194 |         os = RbConfig::CONFIG['target_os']
195 |         out = "{bg}Copying {bw}#{string}".c
196 |         case os
197 |         when /darwin.*/i
198 |           Howzit.console.debug("#{out} (macOS){x}".c)
199 |           `echo #{Shellwords.escape(string)}'\\c'|pbcopy`
200 |         when /mingw|mswin/i
201 |           Howzit.console.debug("#{out} (Windows){x}".c)
202 |           `echo #{Shellwords.escape(string)} | clip`
203 |         else
204 |           if 'xsel'.available?
205 |             Howzit.console.debug("#{out} (Linux, xsel){x}".c)
206 |             `echo #{Shellwords.escape(string)}'\\c'|xsel -i`
207 |           elsif 'xclip'.available?
208 |             Howzit.console.debug("#{out} (Linux, xclip){x}".c)
209 |             `echo #{Shellwords.escape(string)}'\\c'|xclip -i`
210 |           else
211 |             Howzit.console.debug(out)
212 |             Howzit.console.warn('Unable to determine executable for clipboard.')
213 |           end
214 |         end
215 |       end
216 | 
217 |       ##
218 |       ## Platform-agnostic paste-from-clipboard
219 |       ##
220 |       def os_paste
221 |         os = RbConfig::CONFIG['target_os']
222 |         out = "{bg}Pasting from clipboard".c
223 |         case os
224 |         when /darwin.*/i
225 |           Howzit.console.debug("#{out} (macOS){x}".c)
226 |           `pbpaste`
227 |         when /mingw|mswin/i
228 |           Howzit.console.debug("#{out} (Windows){x}".c)
229 |           `cat /dev/clipboard`
230 |         else
231 |           if 'xsel'.available?
232 |             Howzit.console.debug("#{out} (Linux, xsel){x}".c)
233 |             `xsel --clipboard --output`
234 |           elsif 'xclip'.available?
235 |             Howzit.console.debug("#{out} (Linux, xclip){x}".c)
236 |             `xclip -selection clipboard -o`
237 |           else
238 |             Howzit.console.debug(out)
239 |             Howzit.console.warn('Unable to determine executable for clipboard.')
240 |           end
241 |         end
242 |       end
243 | 
244 |       ##
245 |       ## Platform-agnostic open command
246 |       ##
247 |       ## @param      command  [String] The command
248 |       ##
249 |       def os_open(command)
250 |         os = RbConfig::CONFIG['target_os']
251 |         out = "{bg}Opening {bw}#{command}".c
252 |         case os
253 |         when /darwin.*/i
254 |           Howzit.console.debug "#{out} (macOS){x}".c if Howzit.options[:log_level] < 2
255 |           `open #{Shellwords.escape(command)}`
256 |         when /mingw|mswin/i
257 |           Howzit.console.debug "#{out} (Windows){x}".c if Howzit.options[:log_level] < 2
258 |           `start #{Shellwords.escape(command)}`
259 |         else
260 |           if 'xdg-open'.available?
261 |             Howzit.console.debug "#{out} (Linux){x}".c if Howzit.options[:log_level] < 2
262 |             `xdg-open #{Shellwords.escape(command)}`
263 |           else
264 |             Howzit.console.debug out if Howzit.options[:log_level] < 2
265 |             Howzit.console.debug 'Unable to determine executable for `open`.'
266 |           end
267 |         end
268 |       end
269 |     end
270 |   end
271 | end
272 | 


--------------------------------------------------------------------------------
/bin/howzit:
--------------------------------------------------------------------------------
  1 | #!/usr/bin/env ruby
  2 | # frozen_string_literal: true
  3 | 
  4 | $LOAD_PATH.unshift File.join(__dir__, "..", "lib")
  5 | require "howzit"
  6 | 
  7 | Howzit::Color.coloring = $stdout.isatty
  8 | 
  9 | parts = Shellwords.shelljoin(ARGV).split(/ -- /)
 10 | args = parts[0] ? Shellwords.shellsplit(parts[0]) : []
 11 | Howzit.arguments = parts[1] ? Shellwords.shellsplit(parts[1]) : []
 12 | Howzit.named_arguments = {}
 13 | 
 14 | OptionParser.new do |opts|
 15 |   opts.banner = "Usage: #{File.basename(__FILE__)} [OPTIONS] [TOPIC]"
 16 |   opts.separator ""
 17 |   opts.separator "Show build notes for the current project (buildnotes.md).
 18 |   Include a topic name to see just that topic, or no argument to display all."
 19 |   opts.separator ""
 20 |   opts.separator "Options:"
 21 | 
 22 |   opts.separator "  Behavior:\n\n" #=================================================================== BEHAVIOR
 23 | 
 24 |   opts.on("--ask", "Request confirmation for all tasks when running a topic") { Howzit.options[:ask] = true }
 25 | 
 26 |   opts.on("--default", "Answer all prompts with default response") do
 27 |     raise "--default cannot be used with --yes or --no" if Howzit.options[:yes] || Howzit.options[:no]
 28 | 
 29 |     Howzit.options[:default] = true
 30 |   end
 31 | 
 32 |   opts.on("-f", "--force", "Continue executing after an error") { Howzit.options[:force] = true }
 33 | 
 34 |   opts.on("-m", "--matching TYPE", MATCHING_OPTIONS,
 35 |           "Topics matching type", "(#{MATCHING_OPTIONS.join(", ").sub(/#{Howzit.options[:matching]}/, "*#{Howzit.options[:matching]}")})") do |c|
 36 |     Howzit.options[:matching] = c
 37 |   end
 38 | 
 39 |   opts.on("--multiple TYPE", MULTIPLE_OPTIONS,
 40 |           "Multiple result handling", "(#{MULTIPLE_OPTIONS.join(", ").sub(/#{Howzit.options[:multiple_matches]}/, "*#{Howzit.options[:multiple_matches]}")}, default choose)") do |c|
 41 |     Howzit.options[:multiple_matches] = c.to_sym
 42 |   end
 43 | 
 44 |   opts.on("-u", "--[no-]upstream", "Traverse up parent directories for additional build notes") do |p|
 45 |     Howzit.options[:include_upstream] = p
 46 |   end
 47 | 
 48 |   opts.on("-y", "--yes", "Answer yes to all prompts") do
 49 |     raise "--default cannot be used with --yes" if Howzit.options[:default]
 50 | 
 51 |     Howzit.options[:yes] = true
 52 |   end
 53 | 
 54 |   opts.on("-n", "--no", "Answer no to all prompts") do
 55 |     raise "--default cannot be used with --no" if Howzit.options[:default]
 56 | 
 57 |     Howzit.options[:no] = true
 58 |   end
 59 | 
 60 |   opts.separator "\n  Listing:\n\n" #=================================================================== LISTING
 61 | 
 62 |   opts.on("-L", "--list-completions", "List topics (completion-compatible)") do
 63 |     Howzit.options[:list_topics] = true
 64 |     Howzit.options[:list_topic_titles] = true
 65 |   end
 66 | 
 67 |   opts.on("-l", "--list", "List available topics") do
 68 |     Howzit.options[:list_topics] = true
 69 |   end
 70 | 
 71 |   opts.on("-R", "--list-runnable [PATTERN]", "List topics containing @ directives (verbose)") do |pat|
 72 |     Howzit.options[:for_topic] = pat
 73 |     Howzit.options[:list_runnable] = true
 74 |   end
 75 | 
 76 |   opts.on("-T", "--task-list", "List topics containing @ directives (completion-compatible)") do
 77 |     Howzit.options[:list_runnable] = true
 78 |     Howzit.options[:list_runnable_titles] = true
 79 |   end
 80 | 
 81 |   opts.on("--templates", "List available templates") do
 82 |     out = []
 83 |     Dir.chdir(Howzit.config.template_folder)
 84 |     Dir.glob("*.md").each do |file|
 85 |       template = File.basename(file, ".md")
 86 |       out.push(Howzit::Color.template("{Mk}template:{Yk}#{template}{x}"))
 87 |       out.push(Howzit::Color.template("{bk}[{bl}tasks{bk}]──────────────────────────────────────┐{x}"))
 88 |       metadata = file.extract_metadata
 89 |       topics = Howzit::BuildNote.new(file: file).topics
 90 |       topics.each do |topic|
 91 |         out.push(Howzit::Color.template(" {bk}│{bw}-{x} {bcK}#{template}:#{topic.title.sub(/^.*?:/, "")}{x}"))
 92 |       end
 93 |       unless metadata.empty?
 94 |         meta = []
 95 |         meta << metadata["required"].split(/\s*,\s*/).map { |m| "*{bw}#{m}{xw}" } if metadata.key?("required")
 96 |         meta << metadata["optional"].split(/\s*,\s*/).map(&:to_s) if metadata.key?("optional")
 97 |         out.push(Howzit::Color.template("{bk}[{bl}meta{bk}]───────────────────────────────────────┤{x}"))
 98 |         out.push(Howzit::Color.template(" {bk}│ {xw}#{meta.join(", ")}{x}"))
 99 |       end
100 |       out.push(Howzit::Color.template(" {bk}└───────────────────────────────────────────┘{x}"))
101 |     end
102 |     Howzit::Util.page out.join("\n")
103 |     Process.exit 0
104 |   end
105 | 
106 |   opts.on("--templates-c", "List available templates in a format for completion") do
107 |     out = []
108 |     Dir.chdir(Howzit.config.template_folder)
109 |     Dir.glob("*.md").each do |file|
110 |       template = File.basename(file, ".md")
111 |       out.push(template)
112 |     end
113 |     puts out.join("\n")
114 |     Process.exit 0
115 |   end
116 | 
117 |   opts.on("--title-only", "Output title only") do
118 |     Howzit.options[:output_title] = true
119 |     Howzit.options[:title_only] = true
120 |   end
121 | 
122 |   opts.separator("\n  Commands:\n\n") #=================================================================== COMMANDS
123 | 
124 |   opts.on("-c", "--create", "Create a skeleton build note in the current working directory") do
125 |     Howzit.buildnote.create_note
126 |     Process.exit 0
127 |   end
128 | 
129 |   opts.on("--config-get [KEY]", "Display the configuration settings or setting for a specific key") do |k|
130 |     if k.nil?
131 |       Howzit::Config::DEFAULTS.sort_by { |key, _| key }.each do |key, _|
132 |         print "#{key}: "
133 |         p Howzit.options[key]
134 |       end
135 |     else
136 |       k.sub!(/^:/, "")
137 |       if Howzit.options.key?(k.to_sym)
138 |         puts Howzit.options[k.to_sym]
139 |       else
140 |         puts "Key #{k} not found"
141 |       end
142 |     end
143 |     Process.exit 0
144 |   end
145 | 
146 |   opts.on("--config-set KEY=VALUE", "Set a config value (must be a valid key)") do |key|
147 |     raise "Argument must be KEY=VALUE" unless key =~ /\S=\S/
148 | 
149 |     parts = key.split(/=/)
150 |     k = parts.shift.sub(/^:/, "")
151 |     v = parts.join(" ")
152 | 
153 |     if Howzit.options.key?(k.to_sym)
154 |       Howzit.options[k.to_sym] = v.to_config_value(Howzit.options[k.to_sym])
155 |     else
156 |       puts "Key #{k} not found"
157 |     end
158 |     Howzit.config.write_config(Howzit.options)
159 |     Process.exit 0
160 |   end
161 | 
162 |   desc = %(Edit buildnotes file in current working directory using default editor)
163 |   opts.on("-e", "--edit", desc) do
164 |     Howzit.buildnote.edit
165 |     Process.exit 0
166 |   end
167 | 
168 |   opts.on("--edit-config", "Edit configuration file using default editor") do
169 |     Howzit.config.editor
170 |     Process.exit 0
171 |   end
172 | 
173 |   opts.on("--edit-template NAME", "Create or edit a template") do |template|
174 |     Howzit.buildnote.edit_template(template)
175 |     Process.exit 0
176 |   end
177 | 
178 |   opts.on("--grep PATTERN", "Display sections matching a search pattern") do |pat|
179 |     Howzit.options[:grep] = pat
180 |   end
181 | 
182 |   opts.on("--hook", "Copy a link to the build note file, ready for pasting into Hook.app or other notes") do
183 |     Howzit.buildnote.hook
184 |     Process.exit 0
185 |   end
186 | 
187 |   opts.on("-r", "--run", "Execute @run, @open, and/or @copy commands for given topic") do
188 |     Howzit.options[:run] = true
189 |   end
190 | 
191 |   opts.on("-s", "--select", "Select topic from menu") do
192 |     Howzit.options[:choose] = true
193 |   end
194 | 
195 |   opts.separator("\n  Formatting:\n\n") #=================================================================== FORMATTING
196 | 
197 |   opts.on("--[no-]color", "Colorize output (default on)") do |c|
198 |     Howzit.options[:color] = c
199 |     Howzit.options[:highlight] = false unless c
200 |   end
201 | 
202 |   opts.on("--header-format TYPE", HEADER_FORMAT_OPTIONS,
203 |           "Formatting style for topic titles (#{HEADER_FORMAT_OPTIONS.join(", ")})") do |t|
204 |     Howzit.options[:header_format] = t
205 |   end
206 | 
207 |   opts.on("--[no-]md-highlight", "Highlight Markdown syntax (default on), requires mdless or mdcat") do |m|
208 |     Howzit.options[:highlight] = Howzit.options[:color] ? m : false
209 |   end
210 | 
211 |   opts.on("--[no-]pager", "Paginate output (default on)") do |p|
212 |     Howzit.options[:paginate] = p
213 |   end
214 | 
215 |   opts.on("--show-code", "Display the content of fenced run blocks") do
216 |     Howzit.options[:show_all_code] = true
217 |   end
218 | 
219 |   opts.on("-t", "--title", "Output title with build notes") do
220 |     Howzit.options[:output_title] = true
221 |   end
222 | 
223 |   opts.on("-w", "--wrap COLUMNS", "Wrap to specified width (default 80, 0 to disable)") do |w|
224 |     Howzit.options[:wrap] = w.to_i
225 |   end
226 | 
227 |   opts.separator("\n  Logging:\n\n") #=================================================================== LOGGING
228 | 
229 |   opts.on("-d", "--debug", "Show debug messages (and all messages)") do
230 |     Howzit.options[:log_level] = 0
231 |     Howzit.console.reset_level
232 |   end
233 | 
234 |   opts.on("-q", "--quiet", "Silence info message") do
235 |     Howzit.options[:log_level] = 4
236 |     Howzit.console.reset_level
237 |   end
238 | 
239 |   opts.on("--verbose", "Show all messages") do
240 |     Howzit.options[:log_level] = 1
241 |     Howzit.console.reset_level
242 |   end
243 | 
244 |   opts.separator("\n  Misc:\n\n") #=================================================================== MISC
245 | 
246 |   opts.on("-h", "--help", "Display this screen") do
247 |     Howzit::Util.page opts.to_s
248 |     Process.exit 0
249 |   end
250 | 
251 |   opts.on("-v", "--version", "Display version number") do
252 |     puts "#{File.basename(__FILE__)} v#{Howzit::VERSION}"
253 |     Process.exit 0
254 |   end
255 | end.parse!(args)
256 | 
257 | trap("INT") do
258 |   puts
259 |   puts "Cancelled"
260 |   Process.exit 0
261 | end
262 | 
263 | Howzit.options[:multiple_matches] = Howzit.options[:multiple_matches].to_sym
264 | Howzit.options[:header_format] = Howzit.options[:header_format].to_sym
265 | 
266 | Howzit.cli_args = args
267 | Howzit.buildnote.run
268 | 


--------------------------------------------------------------------------------
/lib/howzit/colors.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | # Cribbed from 
  4 | module Howzit
  5 |   # Terminal output color functions.
  6 |   module Color
  7 |     # Regexp to match excape sequences
  8 |     ESCAPE_REGEX = /(?<=\[)(?:(?:(?:[349]|10)[0-9]|[0-9])?;?)+(?=m)/.freeze
  9 | 
 10 |     # All available color names. Available as methods and string extensions.
 11 |     #
 12 |     # @example Use a color as a method. Color reset will be added to end of string.
 13 |     #   Color.yellow('This text is yellow') => "\e[33mThis text is yellow\e[0m"
 14 |     #
 15 |     # @example Use a color as a string extension. Color reset added automatically.
 16 |     #   'This text is green'.green => "\e[1;32mThis text is green\e[0m"
 17 |     #
 18 |     # @example Send a text string as a color
 19 |     #   Color.send('red') => "\e[31m"
 20 |     ATTRIBUTES = [
 21 |       [:clear,               0], # String#clear is already used to empty string in Ruby 1.9
 22 |       [:reset,               0], # synonym for :clear
 23 |       [:bold,                1],
 24 |       [:dark,                2],
 25 |       [:italic,              3], # not widely implemented
 26 |       [:underline,           4],
 27 |       [:underscore,          4], # synonym for :underline
 28 |       [:blink,               5],
 29 |       [:rapid_blink,         6], # not widely implemented
 30 |       [:negative,            7], # no reverse because of String#reverse
 31 |       [:concealed,           8],
 32 |       [:strikethrough,       9], # not widely implemented
 33 |       [:strike,              9], # not widely implemented
 34 |       [:black,              30],
 35 |       [:red,                31],
 36 |       [:green,              32],
 37 |       [:yellow,             33],
 38 |       [:blue,               34],
 39 |       [:magenta,            35],
 40 |       [:purple,             35],
 41 |       [:cyan,               36],
 42 |       [:white,              37],
 43 |       [:bgblack,            40],
 44 |       [:bgred,              41],
 45 |       [:bggreen,            42],
 46 |       [:bgyellow,           43],
 47 |       [:bgblue,             44],
 48 |       [:bgmagenta,          45],
 49 |       [:bgpurple,           45],
 50 |       [:bgcyan,             46],
 51 |       [:bgwhite,            47],
 52 |       [:boldblack,          90],
 53 |       [:boldred,            91],
 54 |       [:boldgreen,          92],
 55 |       [:boldyellow,         93],
 56 |       [:boldblue,           94],
 57 |       [:boldmagenta,        95],
 58 |       [:boldpurple,         95],
 59 |       [:boldcyan,           96],
 60 |       [:boldwhite,          97],
 61 |       [:boldbgblack,       100],
 62 |       [:boldbgred,         101],
 63 |       [:boldbggreen,       102],
 64 |       [:boldbgyellow,      103],
 65 |       [:boldbgblue,        104],
 66 |       [:boldbgmagenta,     105],
 67 |       [:boldbgpurple,      105],
 68 |       [:boldbgcyan,        106],
 69 |       [:boldbgwhite,       107],
 70 |       [:softpurple,  '0;35;40'],
 71 |       [:hotpants,    '7;34;40'],
 72 |       [:knightrider, '7;30;40'],
 73 |       [:flamingo,    '7;31;47'],
 74 |       [:yeller,      '1;37;43'],
 75 |       [:whiteboard,  '1;30;47'],
 76 |       [:chalkboard,  '1;37;40'],
 77 |       [:led,         '0;32;40'],
 78 |       [:redacted,    '0;30;40'],
 79 |       [:alert,       '1;31;43'],
 80 |       [:error,       '1;37;41'],
 81 |       [:default, '0;39']
 82 |     ].map(&:freeze).freeze
 83 | 
 84 |     # Array of attribute keys only
 85 |     ATTRIBUTE_NAMES = ATTRIBUTES.transpose.first
 86 | 
 87 |     # Returns true if Howzit::Color supports the +feature+.
 88 |     #
 89 |     # The feature :clear, that is mixing the clear color attribute into String,
 90 |     # is only supported on ruby implementations, that do *not* already
 91 |     # implement the String#clear method. It's better to use the reset color
 92 |     # attribute instead.
 93 |     def support?(feature)
 94 |       case feature
 95 |       when :clear
 96 |         !String.instance_methods(false).map(&:to_sym).include?(:clear)
 97 |       end
 98 |     end
 99 | 
100 |     # Template coloring
101 |     class ::String
102 |       ##
103 |       ## Extract the longest valid %color name from a string.
104 |       ##
105 |       ## Allows %colors to bleed into other text and still
106 |       ## be recognized, e.g. %greensomething still finds
107 |       ## %green.
108 |       ##
109 |       ## @return     [String] a valid color name
110 |       ##
111 |       def validate_color
112 |         valid_color = nil
113 |         compiled = ''
114 |         normalize_color.split('').each do |char|
115 |           compiled += char
116 |           if Color.attributes.include?(compiled.to_sym) || compiled =~ /^([fb]g?)?#([a-f0-9]{6})$/i
117 |             valid_color = compiled
118 |           end
119 |         end
120 | 
121 |         valid_color
122 |       end
123 | 
124 |       ##
125 |       ## Normalize a color name, removing underscores,
126 |       ## replacing "bright" with "bold", and converting
127 |       ## bgbold to boldbg
128 |       ##
129 |       ## @return     [String] Normalized color name
130 |       ##
131 |       def normalize_color
132 |         gsub(/_/, '').sub(/bright/i, 'bold').sub(/bgbold/, 'boldbg')
133 |       end
134 | 
135 |       # Get the calculated ANSI color at the end of the
136 |       # string
137 |       #
138 |       # @return     ANSI escape sequence to match color
139 |       #
140 |       def last_color_code
141 |         m = scan(ESCAPE_REGEX)
142 | 
143 |         em = ['0']
144 |         fg = nil
145 |         bg = nil
146 |         rgbf = nil
147 |         rgbb = nil
148 | 
149 |         m.each do |c|
150 |           case c
151 |           when '0'
152 |             em = ['0']
153 |             fg, bg, rgbf, rgbb = nil
154 |           when /^[34]8/
155 |             case c
156 |             when /^3/
157 |               fg = nil
158 |               rgbf = c
159 |             when /^4/
160 |               bg = nil
161 |               rgbb = c
162 |             end
163 |           else
164 |             c.split(/;/).each do |i|
165 |               x = i.to_i
166 |               if x <= 9
167 |                 em << x
168 |               elsif x >= 30 && x <= 39
169 |                 rgbf = nil
170 |                 fg = x
171 |               elsif x >= 40 && x <= 49
172 |                 rgbb = nil
173 |                 bg = x
174 |               elsif x >= 90 && x <= 97
175 |                 rgbf = nil
176 |                 fg = x
177 |               elsif x >= 100 && x <= 107
178 |                 rgbb = nil
179 |                 bg = x
180 |               end
181 |             end
182 |           end
183 |         end
184 | 
185 |         escape = "\e[#{em.join(';')}m"
186 |         escape += "\e[#{rgbb}m" if rgbb
187 |         escape += "\e[#{rgbf}m" if rgbf
188 |         escape + "\e[#{[fg, bg].delete_if(&:nil?).join(';')}m"
189 |       end
190 |     end
191 | 
192 |     class << self
193 |       # Returns true if the coloring function of this module
194 |       # is switched on, false otherwise.
195 |       def coloring?
196 |         @coloring
197 |       end
198 | 
199 |       attr_writer :coloring
200 | 
201 |       ##
202 |       ## Enables colored output
203 |       ##
204 |       ## @example Turn color on or off based on TTY
205 |       ##   Howzit::Color.coloring = STDOUT.isatty
206 |       def coloring
207 |         @coloring ||= true
208 |       end
209 | 
210 |       def translate_rgb(code)
211 |         return code if code.to_s !~ /#[A-Z0-9]{3,6}/i
212 | 
213 |         rgb(code)
214 |       end
215 | 
216 |       ##
217 |       ## Generate escape codes for hex colors
218 |       ##
219 |       ## @param      hex   [String] The hexadecimal color code
220 |       ##
221 |       ## @return     [String] ANSI escape string
222 |       ##
223 |       def rgb(hex)
224 |         is_bg = hex.match(/^bg?#/) ? true : false
225 |         hex_string = hex.sub(/^([fb]g?)?#/, '')
226 | 
227 |         parts = hex_string.match(/(?..)(?..)(?..)/)
228 |         t = []
229 |         %w[r g b].each do |e|
230 |           t << parts[e].hex
231 |         end
232 | 
233 |         "\e[#{is_bg ? '48' : '38'};2;#{t.join(';')}"
234 |       end
235 | 
236 |       # Merge config file colors into attributes
237 |       def configured_colors
238 |         color_file = File.join(File.expand_path(CONFIG_DIR), COLOR_FILE)
239 |         if File.exist?(color_file)
240 |           colors = YAML.load(Util.read_file(color_file))
241 |           return ATTRIBUTES unless !colors.nil? && colors.is_a?(Hash)
242 | 
243 |           attrs = ATTRIBUTES.to_h
244 |           attrs = attrs.merge(colors.symbolize_keys)
245 |           new_colors = {}
246 |           attrs.each { |k, v| new_colors[k] = translate_rgb(v) }
247 |           new_colors.to_a
248 |         else
249 |           ATTRIBUTES
250 |         end
251 |       end
252 | 
253 |       ##
254 |       ## Convert a template string to a colored string.
255 |       ## Colors are specified with single letters inside
256 |       ## curly braces. Uppercase changes background color.
257 |       ##
258 |       ## w: white, k: black, g: green, l: blue, y: yellow, c: cyan,
259 |       ## m: magenta, r: red, b: bold, u: underline, i: italic,
260 |       ## x: reset (remove background, color, emphasis)
261 |       ##
262 |       ## @example Convert a templated string
263 |       ##   Color.template('{Rwb}Warning:{x} {w}you look a little {g}ill{x}')
264 |       ##
265 |       ## @param      input  [String, Array] The template
266 |       ##                    string. If this is an array, the
267 |       ##                    elements will be joined with a
268 |       ##                    space.
269 |       ##
270 |       ## @return     [String] Colorized string
271 |       ##
272 |       def template(input)
273 |         input = input.join(' ') if input.is_a? Array
274 |         fmt = input.gsub(/%/, '%%')
275 |         fmt = fmt.gsub(/(?s" }.join('')
279 |           else
280 |             Regexp.last_match(0)
281 |           end
282 |         end
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 }
289 | 
290 |         result = fmt.empty? ? input : format(fmt, colors)
291 |         # Unescape braces that were escaped to prevent color code interpretation
292 |         result.gsub(/\\\{/, '{').gsub(/\\\}/, '}')
293 |       end
294 |     end
295 | 
296 |     # Dynamically generate methods for each color name. Each
297 |     # resulting method can be called with a string or a block.
298 |     configured_colors.each do |c, v|
299 |       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
312 |           end
313 |           result << "\e[0m" if Howzit::Color.coloring?
314 |           result
315 |         end
316 |       EOSCRIPT
317 | 
318 |       module_eval(new_method)
319 | 
320 |       next unless c =~ /bold/
321 | 
322 |       # Accept brightwhite in addition to boldwhite
323 |       new_method = <<-EOSCRIPT
324 |         # color string as #{c}
325 |         def #{c.to_s.sub(/bold/, 'bright')}(string = nil)
326 |           result = ''
327 |           result << "\e[#{v}m" if Howzit::Color.coloring?
328 |           if block_given?
329 |             result << yield
330 |           elsif string.respond_to?(:to_str)
331 |             result << string.to_str
332 |           elsif respond_to?(:to_str)
333 |             result << to_str
334 |           else
335 |             return result #only switch on
336 |           end
337 |           result << "\e[0m" if Howzit::Color.coloring?
338 |           result
339 |         end
340 |       EOSCRIPT
341 | 
342 |       module_eval(new_method)
343 |     end
344 | 
345 |     ##
346 |     ## Generate escape codes for hex colors
347 |     ##
348 |     ## @param      hex   [String] The hexadecimal color code
349 |     ##
350 |     ## @return     [String] ANSI escape string
351 |     ##
352 |     def rgb(hex)
353 |       is_bg = hex.match(/^bg?#/) ? true : false
354 |       hex_string = hex.sub(/^([fb]g?)?#/, '')
355 | 
356 |       parts = hex_string.match(/(?..)(?..)(?..)/)
357 |       t = []
358 |       %w[r g b].each do |e|
359 |         t << parts[e].hex
360 |       end
361 |       "\e[#{is_bg ? '48' : '38'};2;#{t.join(';')}m"
362 |     end
363 | 
364 |     # Regular expression that is used to scan for ANSI-sequences while
365 |     # uncoloring strings.
366 |     COLORED_REGEXP = /\e\[(?:(?:[349]|10)[0-7]|[0-9])?m/.freeze
367 | 
368 |     # Returns an uncolored version of the string, that is all
369 |     # ANSI-sequences are stripped from the string.
370 |     def uncolor(string = nil) # :yields:
371 |       if block_given?
372 |         yield.to_str.gsub(COLORED_REGEXP, '')
373 |       elsif string.respond_to?(:to_str)
374 |         string.to_str.gsub(COLORED_REGEXP, '')
375 |       elsif respond_to?(:to_str)
376 |         to_str.gsub(COLORED_REGEXP, '')
377 |       else
378 |         ''
379 |       end
380 |     end
381 | 
382 |     # Returns an array of all Howzit::Color attributes as symbols.
383 |     def attributes
384 |       ATTRIBUTE_NAMES
385 |     end
386 |     extend self
387 |   end
388 | end
389 | 


--------------------------------------------------------------------------------
/lib/howzit/prompt.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | module Howzit
  4 |   # Command line prompt utils
  5 |   module Prompt
  6 |     class << self
  7 |       ##
  8 |       ## Display and read a Yes/No prompt
  9 |       ##
 10 |       ## @param      prompt   [String] The prompt string
 11 |       ## @param      default  [Boolean] default value if
 12 |       ##                      return is pressed or prompt is
 13 |       ##                      skipped
 14 |       ##
 15 |       ## @return     [Boolean] result
 16 |       ##
 17 |       def yn(prompt, default: true)
 18 |         return default unless $stdout.isatty
 19 | 
 20 |         return true if Howzit.options[:yes]
 21 | 
 22 |         return false if Howzit.options[:no]
 23 | 
 24 |         return default if Howzit.options[:default]
 25 | 
 26 |         tty_state = `stty -g`
 27 |         system 'stty raw -echo cbreak isig'
 28 |         yn = color_single_options(default ? %w[Y n] : %w[y N])
 29 |         $stdout.syswrite "\e[1;37m#{prompt} #{yn}\e[1;37m? \e[0m"
 30 |         res = $stdin.sysread 1
 31 |         res.chomp!
 32 |         puts
 33 |         system 'stty cooked'
 34 |         system "stty #{tty_state}"
 35 |         res.empty? ? default : res =~ /y/i
 36 |       end
 37 | 
 38 |       ##
 39 |       ## Helper function to colorize the Y/N prompt
 40 |       ##
 41 |       ## @param      choices  [Array] The choices with
 42 |       ##                      default capitalized
 43 |       ##
 44 |       ## @return     [String] colorized string
 45 |       ##
 46 |       def color_single_options(choices = %w[y n])
 47 |         out = []
 48 |         choices.each do |choice|
 49 |           case choice
 50 |           when /[A-Z]/
 51 |             out.push(Color.template("{bw}#{choice}{x}"))
 52 |           else
 53 |             out.push(Color.template("{dw}#{choice}{xg}"))
 54 |           end
 55 |         end
 56 |         Color.template("{xg}[#{out.join('/')}{xg}]{x}")
 57 |       end
 58 | 
 59 |       ##
 60 |       ## Create a numbered list of options. Outputs directly
 61 |       ## to console, returns nothing
 62 |       ##
 63 |       ## @param      matches  [Array] The list items
 64 |       ##
 65 |       def options_list(matches)
 66 |         counter = 1
 67 |         puts
 68 |         matches.each do |match|
 69 |           printf("%2d ) %