Module: Hammer::Shell

Included in:
Hammer
Defined in:
lib/hammer/shell.rb

Overview

ANSI color/output helpers. Mixed into command instances; also callable directly as Hammer::Shell.say(...).

Defined Under Namespace

Classes: SayProxy

Class Method Summary collapse

Class Method Details

.ask(prompt, default: nil) ⇒ Object



70
71
72
73
74
75
76
77
# File 'lib/hammer/shell.rb', line 70

def ask(prompt, default: nil)
  suffix = default ? " [#{default}]" : ''
  print paint("#{prompt}#{suffix}: ", :cyan)
  line = $stdin.gets
  return default if line.nil?
  line = line.chomp
  line.empty? ? default : line
end

.choose(prompt, items, skip: nil) ⇒ Object

Arrow-key picker. Returns the chosen index, or nil on cancel (ESC, Ctrl-C, q). Non-TTY input falls back to a numbered prompt so this stays scriptable.

idx = choose 'Pick env', %w[dev staging prod]
say.green "chose #{ %w[dev staging prod][idx] }" if idx

skip marks rows that are shown but never landed on, so a list can carry its own headings and rules. They are drawn in gray, the cursor steps over them, and the returned index still counts every row:

choose 'Pick', ['-- fast --', 'dev', '-- slow --', 'prod'],
     skip: ->(item) { item.start_with?('--') }


98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/hammer/shell.rb', line 98

def choose(prompt, items, skip: nil)
  items = items.to_a
  error 'choose needs at least one item' if items.empty?

  live = items.each_index.reject { |i| skip&.call(items[i]) }
  error 'choose needs at least one selectable item' if live.empty?

  say.cyan prompt

  return choose_numbered(items, live) unless $stdin.tty? && $stdin.respond_to?(:raw)

  selected = live.first
  # In raw mode \n is not translated to \r\n, so the picker uses \r\n
  # explicitly. The initial draw happens in cooked mode but \r\n is
  # harmless there.
  redraw = lambda do |highlight = :cyan|
    items.each_with_index do |item, i|
      line = if !live.include?(i) then paint("  #{item}", :gray)
             elsif i == selected  then paint("> #{item}", highlight)
             else                      "  #{item}"
             end
      $stdout.print "#{line}\r\n"
    end
  end
  redraw.call

  # Move by position among the selectable rows, so skipped ones are
  # stepped over in both directions and the wrap-around still works.
  step = ->(dir) { live[(live.index(selected) + dir) % live.size] }

  $stdout.print "\e[?25l" # hide cursor
  begin
    $stdin.raw do |io|
      loop do
        ch = io.getch
        case ch
        when "\r", "\n"
          # Collapse the list to the chosen line, in green.
          $stdout.print "\e[#{items.size}A\r\e[J"
          $stdout.print "#{paint("> #{items[selected]}", :green)}\r\n"
          return selected
        when "\x03" # Ctrl-C
          $stdout.print "\e[#{items.size}A\r\e[J"
          raise Interrupt
        when "\e"
          # ESC may stand alone or start an arrow sequence \e[A / \e[B.
          if IO.select([io], nil, nil, 0.01) && io.getch == '['
            case io.getch
            when 'A' then selected = step.call(-1)
            when 'B' then selected = step.call(1)
            end
          else
            $stdout.print "\e[#{items.size}A\r\e[J"
            return nil
          end
        when 'k' then selected = step.call(-1)
        when 'j' then selected = step.call(1)
        end
        $stdout.print "\e[#{items.size}A\r\e[J"
        redraw.call
      end
    end
  ensure
    $stdout.print "\e[?25h" # show cursor
  end
end

.choose_numbered(items, live = items.each_index.to_a) ⇒ Object

Fallback for non-TTY stdin (pipes, tests). Returns the index or nil. Skipped rows are still printed, just without a number to type.



167
168
169
170
171
172
173
174
175
176
177
# File 'lib/hammer/shell.rb', line 167

def choose_numbered(items, live = items.each_index.to_a)
  items.each_with_index do |item, i|
    n = live.index(i)
    puts n ? "  #{n + 1}) #{item}" : "     #{item}"
  end
  print paint("select [1-#{live.size}]: ", :cyan)
  line = $stdin.gets
  return nil if line.nil?
  idx = line.strip.to_i - 1
  idx.between?(0, live.size - 1) ? live[idx] : nil
end

.color!(value) ⇒ Object



23
24
25
# File 'lib/hammer/shell.rb', line 23

def color!(value)
  @color = value
end

.color?Boolean

Returns:

  • (Boolean)


15
16
17
18
19
20
21
# File 'lib/hammer/shell.rb', line 15

def color?
  # Only an explicit color!(value) override is sticky; otherwise the
  # tty decision is recomputed so a redirected $stdout (tests, capture
  # blocks) is honored instead of frozen at first read.
  return @color if defined?(@color) && !@color.nil?
  $stdout.tty? && ENV['NO_COLOR'].nil?
end

.error(text) ⇒ Object

Raise a controlled Hammer::Error. If unhandled, the dispatcher prints the message in red and exits 1 - no backtrace, no help spam.

error 'config file missing' unless File.exist?(path)

Raises:

  • (Hammer::Error)


60
61
62
# File 'lib/hammer/shell.rb', line 60

def error(text)
  raise Hammer::Error, text
end

.paint(text, color = nil) ⇒ Object



27
28
29
30
31
32
33
# File 'lib/hammer/shell.rb', line 27

def paint(text, color = nil)
  if color && !COLORS.key?(color)
    raise Hammer::Error, "unknown color #{color.inspect} (valid: #{COLORS.keys.join(', ')})"
  end
  return text.to_s unless color? && color
  "\e[#{COLORS[color]}m#{text}\e[0m"
end

Print a red [error] line to stderr (does not exit). Used internally by the dispatcher to render Hammer::Error messages.



66
67
68
# File 'lib/hammer/shell.rb', line 66

def print_error(text)
  warn paint("[error] #{text}", :red)
end

.say(text = :_say_no_arg, color = nil) ⇒ Object

say with no args returns a proxy so you can write say.cyan 'hi'. say('') still prints a blank line; say('x', :cyan) is unchanged.



37
38
39
40
# File 'lib/hammer/shell.rb', line 37

def say(text = :_say_no_arg, color = nil)
  return SayProxy.new if text == :_say_no_arg
  puts paint(text, color)
end

.sh(cmd) ⇒ Object

Run a shell command. Echoes the command in gray, raises Hammer::Error on non-zero exit. Returns true on success.



181
182
183
184
185
# File 'lib/hammer/shell.rb', line 181

def sh(cmd)
  say "$ #{cmd}", :gray
  error "command failed: #{cmd}" unless system(cmd)
  true
end

.yes?(prompt) ⇒ Boolean

Returns:

  • (Boolean)


79
80
81
82
83
# File 'lib/hammer/shell.rb', line 79

def yes?(prompt)
  answer = ask("#{prompt} (y/N)")
  return false if answer.nil?
  answer.to_s.strip.downcase.start_with?('y')
end