clack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +24 -0
- data/README.md +424 -0
- data/exe/clack-demo +9 -0
- data/lib/clack/box.rb +120 -0
- data/lib/clack/colors.rb +55 -0
- data/lib/clack/core/cursor.rb +61 -0
- data/lib/clack/core/key_reader.rb +45 -0
- data/lib/clack/core/options_helper.rb +96 -0
- data/lib/clack/core/prompt.rb +215 -0
- data/lib/clack/core/settings.rb +97 -0
- data/lib/clack/core/text_input_helper.rb +83 -0
- data/lib/clack/environment.rb +137 -0
- data/lib/clack/group.rb +100 -0
- data/lib/clack/log.rb +42 -0
- data/lib/clack/note.rb +49 -0
- data/lib/clack/prompts/autocomplete.rb +162 -0
- data/lib/clack/prompts/autocomplete_multiselect.rb +280 -0
- data/lib/clack/prompts/confirm.rb +100 -0
- data/lib/clack/prompts/group_multiselect.rb +250 -0
- data/lib/clack/prompts/multiselect.rb +185 -0
- data/lib/clack/prompts/password.rb +77 -0
- data/lib/clack/prompts/path.rb +226 -0
- data/lib/clack/prompts/progress.rb +145 -0
- data/lib/clack/prompts/select.rb +134 -0
- data/lib/clack/prompts/select_key.rb +100 -0
- data/lib/clack/prompts/spinner.rb +206 -0
- data/lib/clack/prompts/tasks.rb +131 -0
- data/lib/clack/prompts/text.rb +93 -0
- data/lib/clack/stream.rb +82 -0
- data/lib/clack/symbols.rb +84 -0
- data/lib/clack/task_log.rb +174 -0
- data/lib/clack/utils.rb +135 -0
- data/lib/clack/validators.rb +145 -0
- data/lib/clack/version.rb +5 -0
- data/lib/clack.rb +576 -0
- metadata +83 -0
data/lib/clack/stream.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module Clack
|
|
7
|
+
# Stream logging utility for iterables, enumerables, and IO streams
|
|
8
|
+
# Similar to Log but works with streaming data in real-time
|
|
9
|
+
module Stream
|
|
10
|
+
class << self
|
|
11
|
+
def info(source, output: $stdout, &block)
|
|
12
|
+
stream_with_symbol(source, Symbols::S_INFO, :cyan, output, &block)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def success(source, output: $stdout, &block)
|
|
16
|
+
stream_with_symbol(source, Symbols::S_SUCCESS, :green, output, &block)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def step(source, output: $stdout, &block)
|
|
20
|
+
stream_with_symbol(source, Symbols::S_STEP_SUBMIT, :green, output, &block)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def warn(source, output: $stdout, &block)
|
|
24
|
+
stream_with_symbol(source, Symbols::S_WARN, :yellow, output, &block)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def error(source, output: $stdout, &block)
|
|
28
|
+
stream_with_symbol(source, Symbols::S_ERROR, :red, output, &block)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def message(source, output: $stdout)
|
|
32
|
+
each_line(source) do |line|
|
|
33
|
+
output.puts "#{Colors.gray(Symbols::S_BAR)} #{line.chomp}"
|
|
34
|
+
output.flush
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Stream from a subprocess command
|
|
39
|
+
# Usage: Clack.stream.command("npm install", type: :info)
|
|
40
|
+
# Returns true on success, false on failure or if command cannot be executed
|
|
41
|
+
def command(cmd, type: :info, output: $stdout)
|
|
42
|
+
IO.popen(cmd, err: %i[child out]) do |io|
|
|
43
|
+
send(type, io, output: output)
|
|
44
|
+
end
|
|
45
|
+
$CHILD_STATUS.success?
|
|
46
|
+
rescue Errno::ENOENT
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def stream_with_symbol(source, symbol, color, output)
|
|
53
|
+
first = true
|
|
54
|
+
each_line(source) do |line|
|
|
55
|
+
line = line.chomp
|
|
56
|
+
if first
|
|
57
|
+
output.puts "#{Colors.send(color, symbol)} #{line}"
|
|
58
|
+
first = false
|
|
59
|
+
else
|
|
60
|
+
output.puts "#{Colors.gray(Symbols::S_BAR)} #{line}"
|
|
61
|
+
end
|
|
62
|
+
output.flush
|
|
63
|
+
yield line if block_given?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def each_line(source, &block)
|
|
68
|
+
case source
|
|
69
|
+
when IO, StringIO
|
|
70
|
+
source.each_line(&block)
|
|
71
|
+
when String
|
|
72
|
+
source.each_line(&block)
|
|
73
|
+
else
|
|
74
|
+
# Enumerable (Array, etc.)
|
|
75
|
+
source.each do |item|
|
|
76
|
+
block.call(item.to_s)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Symbols
|
|
5
|
+
class << self
|
|
6
|
+
# Check if unicode output is enabled.
|
|
7
|
+
# CLACK_UNICODE=1 forces unicode, CLACK_UNICODE=0 forces ASCII.
|
|
8
|
+
# Otherwise auto-detects from TTY and TERM.
|
|
9
|
+
def unicode?
|
|
10
|
+
return @unicode if defined?(@unicode)
|
|
11
|
+
|
|
12
|
+
@unicode = compute_unicode_support
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def reset!
|
|
16
|
+
remove_instance_variable(:@unicode) if defined?(@unicode)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def compute_unicode_support
|
|
22
|
+
# Explicit override
|
|
23
|
+
return ENV["CLACK_UNICODE"] == "1" if ENV["CLACK_UNICODE"]
|
|
24
|
+
|
|
25
|
+
# Default: TTY and not dumb terminal
|
|
26
|
+
$stdout.tty? && ENV["TERM"] != "dumb" && !ENV["NO_COLOR"]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Step indicators
|
|
31
|
+
S_STEP_ACTIVE = unicode? ? "◆" : "*"
|
|
32
|
+
S_STEP_CANCEL = unicode? ? "■" : "x"
|
|
33
|
+
S_STEP_ERROR = unicode? ? "▲" : "x"
|
|
34
|
+
S_STEP_SUBMIT = unicode? ? "◇" : "o"
|
|
35
|
+
|
|
36
|
+
# Radio buttons
|
|
37
|
+
S_RADIO_ACTIVE = unicode? ? "●" : ">"
|
|
38
|
+
S_RADIO_INACTIVE = unicode? ? "○" : " "
|
|
39
|
+
|
|
40
|
+
# Checkboxes
|
|
41
|
+
S_CHECKBOX_ACTIVE = unicode? ? "◻" : "[•]"
|
|
42
|
+
S_CHECKBOX_SELECTED = unicode? ? "◼" : "[+]"
|
|
43
|
+
S_CHECKBOX_INACTIVE = unicode? ? "◻" : "[ ]"
|
|
44
|
+
|
|
45
|
+
# Password mask
|
|
46
|
+
S_PASSWORD_MASK = unicode? ? "▪" : "*"
|
|
47
|
+
|
|
48
|
+
# Bars and connectors
|
|
49
|
+
S_BAR = unicode? ? "│" : "|"
|
|
50
|
+
S_BAR_START = unicode? ? "┌" : "+"
|
|
51
|
+
S_BAR_END = unicode? ? "└" : "+"
|
|
52
|
+
S_BAR_H = unicode? ? "─" : "-"
|
|
53
|
+
S_CORNER_TOP_RIGHT = unicode? ? "╮" : "+"
|
|
54
|
+
S_CORNER_TOP_LEFT = unicode? ? "╭" : "+"
|
|
55
|
+
S_CORNER_BOTTOM_RIGHT = unicode? ? "╯" : "+"
|
|
56
|
+
S_CORNER_BOTTOM_LEFT = unicode? ? "╰" : "+"
|
|
57
|
+
S_CONNECT_LEFT = unicode? ? "├" : "+"
|
|
58
|
+
|
|
59
|
+
# Square corners (for box with rounded: false)
|
|
60
|
+
S_BAR_START_RIGHT = unicode? ? "┐" : "+"
|
|
61
|
+
S_BAR_END_RIGHT = unicode? ? "┘" : "+"
|
|
62
|
+
|
|
63
|
+
# Log symbols
|
|
64
|
+
S_INFO = unicode? ? "●" : "*"
|
|
65
|
+
S_SUCCESS = unicode? ? "◆" : "*"
|
|
66
|
+
S_WARN = unicode? ? "▲" : "!"
|
|
67
|
+
S_ERROR = unicode? ? "■" : "x"
|
|
68
|
+
|
|
69
|
+
# File system
|
|
70
|
+
S_FOLDER = unicode? ? "📁" : "[D]"
|
|
71
|
+
S_FILE = unicode? ? "📄" : "[F]"
|
|
72
|
+
|
|
73
|
+
# Spinner frames - quarter circle rotation pattern
|
|
74
|
+
SPINNER_FRAMES = unicode? ? %w[◒ ◐ ◓ ◑] : %w[• o O 0]
|
|
75
|
+
SPINNER_DELAY = unicode? ? 0.08 : 0.12
|
|
76
|
+
|
|
77
|
+
# Progress bar characters
|
|
78
|
+
S_PROGRESS_FILLED = unicode? ? "█" : "#"
|
|
79
|
+
S_PROGRESS_EMPTY = unicode? ? "░" : "-"
|
|
80
|
+
|
|
81
|
+
# Alternative progress bar (smoother gradient)
|
|
82
|
+
S_PROGRESS_BLOCKS = unicode? ? %w[░ ▒ ▓ █] : %w[- = # #]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
# A streaming log that clears on success and remains on failure
|
|
5
|
+
# Useful for build output, npm install style streaming, etc.
|
|
6
|
+
#
|
|
7
|
+
# @example Basic usage
|
|
8
|
+
# tl = Clack.task_log(title: "Building...")
|
|
9
|
+
# tl.message("Compiling file 1...")
|
|
10
|
+
# tl.message("Compiling file 2...")
|
|
11
|
+
# tl.success("Build complete!") # Clears the log
|
|
12
|
+
# # or tl.error("Build failed!") # Keeps the log visible
|
|
13
|
+
#
|
|
14
|
+
class TaskLog
|
|
15
|
+
# @param title [String] Title displayed at the top
|
|
16
|
+
# @param limit [Integer, nil] Max lines to show (older lines scroll out)
|
|
17
|
+
# @param retain_log [Boolean] Keep full log history for display on error
|
|
18
|
+
# @param output [IO] Output stream
|
|
19
|
+
def initialize(title:, limit: nil, retain_log: false, output: $stdout)
|
|
20
|
+
@title = title
|
|
21
|
+
@limit = limit
|
|
22
|
+
@retain_log = retain_log
|
|
23
|
+
@output = output
|
|
24
|
+
@buffer = []
|
|
25
|
+
@full_buffer = []
|
|
26
|
+
@groups = []
|
|
27
|
+
@lines_written = 0
|
|
28
|
+
@tty = tty_output?(output)
|
|
29
|
+
|
|
30
|
+
render_title
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Add a message to the log
|
|
34
|
+
# @param msg [String] Message to display
|
|
35
|
+
# @param raw [Boolean] If true, don't add newline between messages
|
|
36
|
+
def message(msg, raw: false)
|
|
37
|
+
clear_buffer
|
|
38
|
+
@buffer << msg.to_s.gsub(/\e\[[\d;]*[ABCDEFGHfJKSTsu]/, "") # Strip cursor movement codes
|
|
39
|
+
apply_limit
|
|
40
|
+
render_buffer if @tty
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Create a named group for messages
|
|
44
|
+
# @param name [String] Group header name
|
|
45
|
+
# @return [TaskLogGroup] Group object with message/success/error methods
|
|
46
|
+
def group(name)
|
|
47
|
+
grp = TaskLogGroup.new(name, self)
|
|
48
|
+
@groups << grp
|
|
49
|
+
grp
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Complete with success - clears the log
|
|
53
|
+
# @param msg [String] Success message
|
|
54
|
+
# @param show_log [Boolean] If true, show the log even on success
|
|
55
|
+
def success(msg, show_log: false)
|
|
56
|
+
clear_all
|
|
57
|
+
@output.puts "#{Colors.green(Symbols::S_STEP_SUBMIT)} #{msg}"
|
|
58
|
+
render_full_buffer if show_log
|
|
59
|
+
reset_buffers
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Complete with error - keeps the log visible
|
|
63
|
+
# @param msg [String] Error message
|
|
64
|
+
# @param show_log [Boolean] If false, hide the log
|
|
65
|
+
def error(msg, show_log: true)
|
|
66
|
+
clear_all
|
|
67
|
+
@output.puts "#{Colors.red(Symbols::S_STEP_ERROR)} #{msg}"
|
|
68
|
+
render_full_buffer if show_log
|
|
69
|
+
reset_buffers
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @api private
|
|
73
|
+
def add_group_message(_group, msg)
|
|
74
|
+
clear_buffer
|
|
75
|
+
@buffer << msg.to_s
|
|
76
|
+
apply_limit
|
|
77
|
+
render_buffer if @tty
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def render_title
|
|
83
|
+
@output.puts Colors.gray(Symbols::S_BAR)
|
|
84
|
+
@output.puts "#{Colors.green(Symbols::S_STEP_SUBMIT)} #{@title}"
|
|
85
|
+
@output.puts Colors.gray(Symbols::S_BAR)
|
|
86
|
+
@lines_written = 3
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def clear_buffer
|
|
90
|
+
return unless @tty && @lines_written.positive?
|
|
91
|
+
|
|
92
|
+
# Move up and clear the buffer lines (not the title)
|
|
93
|
+
buffer_lines = @buffer.sum { |line| line.lines.count }
|
|
94
|
+
return unless buffer_lines.positive?
|
|
95
|
+
|
|
96
|
+
@output.print "\e[#{buffer_lines}A" # Move up
|
|
97
|
+
@output.print "\e[J" # Clear to end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def clear_all
|
|
101
|
+
return unless @tty && @lines_written.positive?
|
|
102
|
+
|
|
103
|
+
total_lines = @lines_written + @buffer.sum { |line| line.lines.count }
|
|
104
|
+
@output.print "\e[#{total_lines}A" # Move up
|
|
105
|
+
@output.print "\e[J" # Clear to end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def render_buffer
|
|
109
|
+
bar = Colors.gray(Symbols::S_BAR)
|
|
110
|
+
@buffer.each do |message|
|
|
111
|
+
print_message_lines(bar, message)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def render_full_buffer
|
|
116
|
+
bar = Colors.gray(Symbols::S_BAR)
|
|
117
|
+
lines = @retain_log ? (@full_buffer + @buffer) : @buffer
|
|
118
|
+
lines.each do |message|
|
|
119
|
+
print_message_lines(bar, message)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def print_message_lines(bar, message)
|
|
124
|
+
message.each_line do |line|
|
|
125
|
+
@output.puts "#{bar} #{Colors.dim(line.chomp)}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def tty_output?(output)
|
|
130
|
+
output.tty?
|
|
131
|
+
rescue NoMethodError
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def apply_limit
|
|
136
|
+
return unless @limit && @buffer.length > @limit
|
|
137
|
+
|
|
138
|
+
overflow = @buffer.shift(@buffer.length - @limit)
|
|
139
|
+
@full_buffer.concat(overflow) if @retain_log
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def reset_buffers
|
|
143
|
+
@buffer.clear
|
|
144
|
+
@full_buffer.clear
|
|
145
|
+
@groups.clear
|
|
146
|
+
@lines_written = 0
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A group within a TaskLog
|
|
151
|
+
class TaskLogGroup
|
|
152
|
+
def initialize(name, parent)
|
|
153
|
+
@name = name
|
|
154
|
+
@parent = parent
|
|
155
|
+
@buffer = []
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Add a message to this group
|
|
159
|
+
def message(msg, raw: false)
|
|
160
|
+
@buffer << msg.to_s
|
|
161
|
+
@parent.add_group_message(self, msg)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Complete group with success
|
|
165
|
+
def success(msg)
|
|
166
|
+
@parent.add_group_message(self, "#{Colors.green("✓")} #{msg}")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Complete group with error
|
|
170
|
+
def error(msg)
|
|
171
|
+
@parent.add_group_message(self, "#{Colors.red("✗")} #{msg}")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
data/lib/clack/utils.rb
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
# Utility functions for text manipulation and formatting
|
|
5
|
+
module Utils
|
|
6
|
+
class << self
|
|
7
|
+
# Strip ANSI escape sequences from text
|
|
8
|
+
# @param text [String] Text with ANSI codes
|
|
9
|
+
# @return [String] Text without ANSI codes
|
|
10
|
+
def strip_ansi(text)
|
|
11
|
+
text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Get visible length of text (excluding ANSI codes)
|
|
15
|
+
# @param text [String] Text potentially containing ANSI codes
|
|
16
|
+
# @return [Integer] Visible character count
|
|
17
|
+
def visible_length(text)
|
|
18
|
+
strip_ansi(text).length
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Wrap text to a specified width, preserving ANSI codes
|
|
22
|
+
# @param text [String] Text to wrap
|
|
23
|
+
# @param width [Integer] Maximum line width
|
|
24
|
+
# @return [String] Wrapped text
|
|
25
|
+
def wrap(text, width)
|
|
26
|
+
return text if width <= 0
|
|
27
|
+
|
|
28
|
+
lines = []
|
|
29
|
+
text.to_s.each_line do |line|
|
|
30
|
+
lines.concat(wrap_line(line.chomp, width))
|
|
31
|
+
end
|
|
32
|
+
lines.join("\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Wrap text with a prefix on each line
|
|
36
|
+
# @param text [String] Text to wrap
|
|
37
|
+
# @param prefix [String] Prefix for each line (e.g., "│ ")
|
|
38
|
+
# @param width [Integer] Total width including prefix
|
|
39
|
+
# @return [String] Wrapped and prefixed text
|
|
40
|
+
def wrap_with_prefix(text, prefix, width)
|
|
41
|
+
prefix_len = visible_length(prefix)
|
|
42
|
+
content_width = width - prefix_len
|
|
43
|
+
return text if content_width <= 0
|
|
44
|
+
|
|
45
|
+
wrapped = wrap(text, content_width)
|
|
46
|
+
wrapped.lines.map { |line| "#{prefix}#{line.chomp}" }.join("\n")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Truncate text to width with ellipsis
|
|
50
|
+
# @param text [String] Text to truncate
|
|
51
|
+
# @param width [Integer] Maximum width
|
|
52
|
+
# @param ellipsis [String] Ellipsis string (default: "...")
|
|
53
|
+
# @return [String] Truncated text
|
|
54
|
+
def truncate(text, width, ellipsis: "...")
|
|
55
|
+
return text if visible_length(text) <= width
|
|
56
|
+
|
|
57
|
+
target = width - ellipsis.length
|
|
58
|
+
return ellipsis if target <= 0
|
|
59
|
+
|
|
60
|
+
# Handle ANSI codes: we need to truncate visible chars while preserving codes
|
|
61
|
+
truncate_visible(text, target) + ellipsis
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def wrap_line(line, width)
|
|
67
|
+
return [line] if visible_length(line) <= width
|
|
68
|
+
|
|
69
|
+
words = line.split(/(\s+)/)
|
|
70
|
+
lines = []
|
|
71
|
+
current = ""
|
|
72
|
+
current_len = 0
|
|
73
|
+
|
|
74
|
+
words.each do |word|
|
|
75
|
+
word_len = visible_length(word)
|
|
76
|
+
|
|
77
|
+
if current_len + word_len <= width
|
|
78
|
+
current += word
|
|
79
|
+
current_len += word_len
|
|
80
|
+
elsif word_len > width
|
|
81
|
+
# Word itself is too long, need to break it
|
|
82
|
+
unless current.empty?
|
|
83
|
+
lines << current.rstrip
|
|
84
|
+
current = ""
|
|
85
|
+
current_len = 0
|
|
86
|
+
end
|
|
87
|
+
lines.concat(break_long_word(word, width))
|
|
88
|
+
else
|
|
89
|
+
lines << current.rstrip unless current.empty?
|
|
90
|
+
current = word.lstrip
|
|
91
|
+
current_len = visible_length(current)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
lines << current.rstrip unless current.empty?
|
|
96
|
+
lines
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def break_long_word(word, width)
|
|
100
|
+
lines = []
|
|
101
|
+
stripped = strip_ansi(word)
|
|
102
|
+
position = 0
|
|
103
|
+
|
|
104
|
+
while position < stripped.length
|
|
105
|
+
lines << stripped[position, width]
|
|
106
|
+
position += width
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
lines
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def truncate_visible(text, target_len)
|
|
113
|
+
result = ""
|
|
114
|
+
visible_count = 0
|
|
115
|
+
position = 0
|
|
116
|
+
|
|
117
|
+
while position < text.length && visible_count < target_len
|
|
118
|
+
if text[position] == "\e" && (match = text[position..].match(/\A\e\[[0-9;]*[a-zA-Z]/))
|
|
119
|
+
# ANSI sequence - include it but don't count
|
|
120
|
+
result += match[0]
|
|
121
|
+
position += match[0].length
|
|
122
|
+
else
|
|
123
|
+
result += text[position]
|
|
124
|
+
visible_count += 1
|
|
125
|
+
position += 1
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Add reset if we have unclosed ANSI codes
|
|
130
|
+
result += "\e[0m" if result.include?("\e[") && !result.end_with?("\e[0m")
|
|
131
|
+
result
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
# Built-in validators for common validation patterns.
|
|
5
|
+
# Use these with the `validate:` option on prompts.
|
|
6
|
+
#
|
|
7
|
+
# @example Using built-in validators
|
|
8
|
+
# Clack.text(message: "Name?", validate: Clack::Validators.required)
|
|
9
|
+
# Clack.text(message: "Email?", validate: Clack::Validators.format(/@/, "Must be an email"))
|
|
10
|
+
# Clack.password(message: "Password?", validate: Clack::Validators.min_length(8))
|
|
11
|
+
#
|
|
12
|
+
# @example Combining validators
|
|
13
|
+
# Clack.text(
|
|
14
|
+
# message: "Username?",
|
|
15
|
+
# validate: Clack::Validators.combine(
|
|
16
|
+
# Clack::Validators.required("Username is required"),
|
|
17
|
+
# Clack::Validators.min_length(3, "Must be at least 3 characters"),
|
|
18
|
+
# Clack::Validators.max_length(20, "Must be at most 20 characters"),
|
|
19
|
+
# Clack::Validators.format(/\A[a-z0-9_]+\z/i, "Only letters, numbers, and underscores")
|
|
20
|
+
# )
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
module Validators
|
|
24
|
+
class << self
|
|
25
|
+
# Validates that the input is not empty.
|
|
26
|
+
#
|
|
27
|
+
# @param message [String] Custom error message
|
|
28
|
+
# @return [Proc] Validator proc
|
|
29
|
+
def required(message = "This field is required")
|
|
30
|
+
->(value) { message if value.to_s.strip.empty? }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Validates minimum length.
|
|
34
|
+
#
|
|
35
|
+
# @param length [Integer] Minimum length
|
|
36
|
+
# @param message [String, nil] Custom error message (supports %d placeholder)
|
|
37
|
+
# @return [Proc] Validator proc
|
|
38
|
+
def min_length(length, message = nil)
|
|
39
|
+
msg = message || "Must be at least #{length} characters"
|
|
40
|
+
->(value) { msg if value.to_s.length < length }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Validates maximum length.
|
|
44
|
+
#
|
|
45
|
+
# @param length [Integer] Maximum length
|
|
46
|
+
# @param message [String, nil] Custom error message
|
|
47
|
+
# @return [Proc] Validator proc
|
|
48
|
+
def max_length(length, message = nil)
|
|
49
|
+
msg = message || "Must be at most #{length} characters"
|
|
50
|
+
->(value) { msg if value.to_s.length > length }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Validates that input matches a regular expression.
|
|
54
|
+
#
|
|
55
|
+
# @param pattern [Regexp] Pattern to match
|
|
56
|
+
# @param message [String] Error message if pattern doesn't match
|
|
57
|
+
# @return [Proc] Validator proc
|
|
58
|
+
def format(pattern, message = "Invalid format")
|
|
59
|
+
->(value) { message unless pattern.match?(value.to_s) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Validates that input is in a list of allowed values.
|
|
63
|
+
#
|
|
64
|
+
# @param allowed [Array] Allowed values
|
|
65
|
+
# @param message [String, nil] Custom error message
|
|
66
|
+
# @return [Proc] Validator proc
|
|
67
|
+
def one_of(allowed, message = nil)
|
|
68
|
+
msg = message || "Must be one of: #{allowed.join(", ")}"
|
|
69
|
+
->(value) { msg unless allowed.include?(value) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Validates that input is a valid integer.
|
|
73
|
+
#
|
|
74
|
+
# @param message [String] Error message
|
|
75
|
+
# @return [Proc] Validator proc
|
|
76
|
+
def integer(message = "Must be a number")
|
|
77
|
+
->(value) { message unless value.to_s.match?(/\A-?\d+\z/) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Validates that input is within a numeric range.
|
|
81
|
+
# Note: Parses value as integer for comparison.
|
|
82
|
+
#
|
|
83
|
+
# @param range [Range] Allowed range
|
|
84
|
+
# @param message [String, nil] Custom error message
|
|
85
|
+
# @return [Proc] Validator proc
|
|
86
|
+
def in_range(range, message = nil)
|
|
87
|
+
msg = message || "Must be between #{range.first} and #{range.last}"
|
|
88
|
+
lambda do |value|
|
|
89
|
+
int_val = value.to_s.to_i
|
|
90
|
+
msg unless range.cover?(int_val) && value.to_s.match?(/\A-?\d+\z/)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Combines multiple validators. Returns the first error message, or nil if all pass.
|
|
95
|
+
#
|
|
96
|
+
# @param validators [Array<Proc>] Validators to combine
|
|
97
|
+
# @return [Proc] Combined validator proc
|
|
98
|
+
def combine(*validators)
|
|
99
|
+
->(value) { first_failing_validation(validators, value) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Common email format validator.
|
|
103
|
+
#
|
|
104
|
+
# @param message [String] Error message
|
|
105
|
+
# @return [Proc] Validator proc
|
|
106
|
+
def email(message = "Must be a valid email address")
|
|
107
|
+
format(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/, message)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Common URL format validator.
|
|
111
|
+
#
|
|
112
|
+
# @param message [String] Error message
|
|
113
|
+
# @return [Proc] Validator proc
|
|
114
|
+
def url(message = "Must be a valid URL")
|
|
115
|
+
format(%r{\Ahttps?://\S+\z}, message)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validates file path exists.
|
|
119
|
+
#
|
|
120
|
+
# @param message [String] Error message
|
|
121
|
+
# @return [Proc] Validator proc
|
|
122
|
+
def path_exists(message = "Path does not exist")
|
|
123
|
+
->(value) { message unless File.exist?(value.to_s) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Validates directory path exists.
|
|
127
|
+
#
|
|
128
|
+
# @param message [String] Error message
|
|
129
|
+
# @return [Proc] Validator proc
|
|
130
|
+
def directory_exists(message = "Directory does not exist")
|
|
131
|
+
->(value) { message unless File.directory?(value.to_s) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def first_failing_validation(validators, value)
|
|
137
|
+
validators.each do |validator|
|
|
138
|
+
result = validator.call(value)
|
|
139
|
+
return result if result
|
|
140
|
+
end
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|