rubish-gem 0.0.1
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/.dockerignore +23 -0
- data/Dockerfile +54 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +12 -0
- data/lib/rubish/arithmetic.rb +140 -0
- data/lib/rubish/ast.rb +168 -0
- data/lib/rubish/builtins/arithmetic.rb +129 -0
- data/lib/rubish/builtins/bind_readline.rb +834 -0
- data/lib/rubish/builtins/directory_stack.rb +182 -0
- data/lib/rubish/builtins/echo_printf.rb +510 -0
- data/lib/rubish/builtins/hash_directories.rb +260 -0
- data/lib/rubish/builtins/read.rb +299 -0
- data/lib/rubish/builtins/trap.rb +324 -0
- data/lib/rubish/codegen.rb +1273 -0
- data/lib/rubish/completion.rb +840 -0
- data/lib/rubish/completions/bash_helpers.rb +530 -0
- data/lib/rubish/completions/git.rb +431 -0
- data/lib/rubish/completions/help_parser.rb +453 -0
- data/lib/rubish/completions/ssh.rb +114 -0
- data/lib/rubish/config.rb +267 -0
- data/lib/rubish/data/builtin_help.rb +716 -0
- data/lib/rubish/data/completion_data.rb +53 -0
- data/lib/rubish/data/readline_config.rb +47 -0
- data/lib/rubish/data/shell_options.rb +251 -0
- data/lib/rubish/data_define.rb +65 -0
- data/lib/rubish/execution_context.rb +1124 -0
- data/lib/rubish/expansion.rb +988 -0
- data/lib/rubish/history.rb +663 -0
- data/lib/rubish/lazy_loader.rb +127 -0
- data/lib/rubish/lexer.rb +1194 -0
- data/lib/rubish/parser.rb +1167 -0
- data/lib/rubish/prompt.rb +766 -0
- data/lib/rubish/repl.rb +2267 -0
- data/lib/rubish/runtime/builtins.rb +7222 -0
- data/lib/rubish/runtime/command.rb +1153 -0
- data/lib/rubish/runtime/job.rb +153 -0
- data/lib/rubish/runtime.rb +1169 -0
- data/lib/rubish/shell_state.rb +241 -0
- data/lib/rubish/startup_profiler.rb +67 -0
- data/lib/rubish/version.rb +5 -0
- data/lib/rubish.rb +60 -0
- data/sig/rubish.rbs +4 -0
- metadata +85 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubish
|
|
4
|
+
# Prompt handling for the shell REPL
|
|
5
|
+
# Supports bash-style (\X) and zsh-style (%X) prompt escapes
|
|
6
|
+
module Prompt
|
|
7
|
+
# Zsh color name to number mapping
|
|
8
|
+
ZSH_COLORS = {'black' => 0, 'red' => 1, 'green' => 2, 'yellow' => 3, 'blue' => 4, 'magenta' => 5, 'cyan' => 6, 'white' => 7, 'default' => 9}.freeze
|
|
9
|
+
|
|
10
|
+
def self.included(base)
|
|
11
|
+
base.class_eval do
|
|
12
|
+
class << self
|
|
13
|
+
attr_accessor :prompt_proc, :right_prompt_proc
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def prompt
|
|
19
|
+
# First check for fish-style prompt function
|
|
20
|
+
if self.class.prompt_proc
|
|
21
|
+
begin
|
|
22
|
+
return instance_exec(&self.class.prompt_proc)
|
|
23
|
+
rescue => e
|
|
24
|
+
$stderr.puts "rubish: prompt error: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Fall back to bash/zsh-style environment variables
|
|
29
|
+
ps1 = ENV['PS1'] || ENV['PROMPT']
|
|
30
|
+
if ps1
|
|
31
|
+
expand_prompt(ps1)
|
|
32
|
+
else
|
|
33
|
+
# Default prompt
|
|
34
|
+
"#{Dir.pwd.sub(ENV['HOME'], '~')}$ "
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def continuation_prompt
|
|
39
|
+
ps2 = ENV['PS2']
|
|
40
|
+
if ps2
|
|
41
|
+
expand_prompt(ps2)
|
|
42
|
+
else
|
|
43
|
+
'> '
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Right prompt (like zsh's RPROMPT)
|
|
48
|
+
def right_prompt
|
|
49
|
+
# First check for fish-style right prompt function
|
|
50
|
+
if self.class.right_prompt_proc
|
|
51
|
+
begin
|
|
52
|
+
result = instance_exec(&self.class.right_prompt_proc)
|
|
53
|
+
return result unless result.nil? || result.empty?
|
|
54
|
+
rescue => e
|
|
55
|
+
$stderr.puts "rubish: right_prompt error: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Fall back to bash/zsh-style environment variables
|
|
60
|
+
rprompt = ENV['RPROMPT'] || ENV['RPS1']
|
|
61
|
+
return nil unless rprompt && !rprompt.empty?
|
|
62
|
+
|
|
63
|
+
expand_prompt(rprompt)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Calculate visible length of a string (excluding ANSI escape codes)
|
|
67
|
+
def visible_length(str)
|
|
68
|
+
# Remove ANSI escape sequences
|
|
69
|
+
str.gsub(/\e\[[0-9;]*[a-zA-Z]/, '').length
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get terminal width
|
|
73
|
+
def terminal_width
|
|
74
|
+
if $stdout.tty?
|
|
75
|
+
begin
|
|
76
|
+
_rows, cols = $stdout.winsize
|
|
77
|
+
cols
|
|
78
|
+
rescue
|
|
79
|
+
ENV['COLUMNS']&.to_i || 80
|
|
80
|
+
end
|
|
81
|
+
else
|
|
82
|
+
80
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def expand_prompt(ps)
|
|
87
|
+
result = +''
|
|
88
|
+
i = 0
|
|
89
|
+
|
|
90
|
+
while i < ps.length
|
|
91
|
+
if ps[i] == '\\'
|
|
92
|
+
# Bash-style escapes: \X
|
|
93
|
+
i += 1
|
|
94
|
+
break if i >= ps.length
|
|
95
|
+
|
|
96
|
+
case ps[i]
|
|
97
|
+
when 'a'
|
|
98
|
+
result << "\a"
|
|
99
|
+
when 'd'
|
|
100
|
+
result << Time.now.strftime('%a %b %d')
|
|
101
|
+
when 'D'
|
|
102
|
+
# \D{format} - custom strftime format
|
|
103
|
+
i += 1
|
|
104
|
+
if i < ps.length && ps[i] == '{'
|
|
105
|
+
i += 1
|
|
106
|
+
fmt_end = ps.index('}', i)
|
|
107
|
+
if fmt_end
|
|
108
|
+
fmt = ps[i...fmt_end]
|
|
109
|
+
result << Time.now.strftime(fmt)
|
|
110
|
+
i = fmt_end
|
|
111
|
+
end
|
|
112
|
+
else
|
|
113
|
+
i -= 1 # Back up, wasn't \D{...}
|
|
114
|
+
result << 'D'
|
|
115
|
+
end
|
|
116
|
+
when 'e'
|
|
117
|
+
result << "\e"
|
|
118
|
+
when 'h'
|
|
119
|
+
result << Socket.gethostname.split('.').first
|
|
120
|
+
when 'H'
|
|
121
|
+
result << Socket.gethostname
|
|
122
|
+
when 'j'
|
|
123
|
+
result << JobManager.instance.active.count.to_s
|
|
124
|
+
when 'l'
|
|
125
|
+
result << (File.basename(`tty`.strip) rescue 'tty')
|
|
126
|
+
when 'n'
|
|
127
|
+
result << "\n"
|
|
128
|
+
when 'r'
|
|
129
|
+
result << "\r"
|
|
130
|
+
when 's'
|
|
131
|
+
result << 'rubish'
|
|
132
|
+
when 't'
|
|
133
|
+
result << Time.now.strftime('%H:%M:%S')
|
|
134
|
+
when 'T'
|
|
135
|
+
result << Time.now.strftime('%I:%M:%S')
|
|
136
|
+
when '@'
|
|
137
|
+
result << Time.now.strftime('%I:%M %p')
|
|
138
|
+
when 'A'
|
|
139
|
+
result << Time.now.strftime('%H:%M')
|
|
140
|
+
when 'u'
|
|
141
|
+
result << (ENV['USER'] || Etc.getlogin || 'user')
|
|
142
|
+
when 'v'
|
|
143
|
+
result << Rubish::VERSION
|
|
144
|
+
when 'V'
|
|
145
|
+
result << Rubish::VERSION
|
|
146
|
+
when 'w'
|
|
147
|
+
home = ENV['HOME'] || ''
|
|
148
|
+
cwd = Dir.pwd
|
|
149
|
+
display_path = home.empty? ? cwd : cwd.sub(/\A#{Regexp.escape(home)}/, '~')
|
|
150
|
+
result << trim_prompt_dir(display_path)
|
|
151
|
+
when 'W'
|
|
152
|
+
cwd = Dir.pwd
|
|
153
|
+
home = ENV['HOME'] || ''
|
|
154
|
+
if cwd == home
|
|
155
|
+
result << '~'
|
|
156
|
+
else
|
|
157
|
+
result << File.basename(cwd)
|
|
158
|
+
end
|
|
159
|
+
when '!'
|
|
160
|
+
result << (Reline::HISTORY.length + 1).to_s
|
|
161
|
+
when '#'
|
|
162
|
+
result << (@command_number || 1).to_s
|
|
163
|
+
when '$'
|
|
164
|
+
result << (Process.uid == 0 ? '#' : '$')
|
|
165
|
+
when '\\'
|
|
166
|
+
result << '\\'
|
|
167
|
+
when '['
|
|
168
|
+
# Begin non-printing sequence (for terminal escape codes)
|
|
169
|
+
# We just skip this marker
|
|
170
|
+
when ']'
|
|
171
|
+
# End non-printing sequence
|
|
172
|
+
# We just skip this marker
|
|
173
|
+
when '0', '1', '2', '3', '4', '5', '6', '7'
|
|
174
|
+
# Octal character \nnn
|
|
175
|
+
octal = ps[i]
|
|
176
|
+
while i + 1 < ps.length && ps[i + 1] =~ /[0-7]/ && octal.length < 3
|
|
177
|
+
i += 1
|
|
178
|
+
octal << ps[i]
|
|
179
|
+
end
|
|
180
|
+
result << octal.to_i(8).chr
|
|
181
|
+
else
|
|
182
|
+
# Unknown escape, keep literal
|
|
183
|
+
result << '\\' << ps[i]
|
|
184
|
+
end
|
|
185
|
+
i += 1
|
|
186
|
+
elsif ps[i] == '%'
|
|
187
|
+
# Zsh-style escapes: %X
|
|
188
|
+
i += 1
|
|
189
|
+
break if i >= ps.length
|
|
190
|
+
|
|
191
|
+
case ps[i]
|
|
192
|
+
when 'n'
|
|
193
|
+
result << (ENV['USER'] || Etc.getlogin || 'user')
|
|
194
|
+
when 'm'
|
|
195
|
+
result << Socket.gethostname.split('.').first
|
|
196
|
+
when 'M'
|
|
197
|
+
result << Socket.gethostname
|
|
198
|
+
when '~'
|
|
199
|
+
home = ENV['HOME'] || ''
|
|
200
|
+
cwd = Dir.pwd
|
|
201
|
+
display_path = home.empty? ? cwd : cwd.sub(/\A#{Regexp.escape(home)}/, '~')
|
|
202
|
+
result << trim_prompt_dir(display_path)
|
|
203
|
+
when '/'
|
|
204
|
+
result << Dir.pwd
|
|
205
|
+
when 'd'
|
|
206
|
+
result << Dir.pwd
|
|
207
|
+
when '.'
|
|
208
|
+
# %. - basename like %1~
|
|
209
|
+
cwd = Dir.pwd
|
|
210
|
+
home = ENV['HOME'] || ''
|
|
211
|
+
result << (cwd == home ? '~' : File.basename(cwd))
|
|
212
|
+
when '1', '2', '3', '4', '5', '6', '7', '8', '9'
|
|
213
|
+
# %N~ - last N path components
|
|
214
|
+
n = ps[i].to_i
|
|
215
|
+
i += 1
|
|
216
|
+
if i < ps.length && ps[i] == '~'
|
|
217
|
+
home = ENV['HOME'] || ''
|
|
218
|
+
cwd = Dir.pwd
|
|
219
|
+
display_path = home.empty? ? cwd : cwd.sub(/\A#{Regexp.escape(home)}/, '~')
|
|
220
|
+
components = display_path.split('/')
|
|
221
|
+
if components.length > n
|
|
222
|
+
result << components.last(n).join('/')
|
|
223
|
+
else
|
|
224
|
+
result << display_path
|
|
225
|
+
end
|
|
226
|
+
else
|
|
227
|
+
i -= 1
|
|
228
|
+
result << '%' << ps[i]
|
|
229
|
+
end
|
|
230
|
+
when 'T'
|
|
231
|
+
result << Time.now.strftime('%H:%M')
|
|
232
|
+
when 't', '@'
|
|
233
|
+
result << Time.now.strftime('%I:%M %p')
|
|
234
|
+
when '*'
|
|
235
|
+
result << Time.now.strftime('%H:%M:%S')
|
|
236
|
+
when 'D'
|
|
237
|
+
# %D or %D{format}
|
|
238
|
+
i += 1
|
|
239
|
+
if i < ps.length && ps[i] == '{'
|
|
240
|
+
i += 1
|
|
241
|
+
fmt_end = ps.index('}', i)
|
|
242
|
+
if fmt_end
|
|
243
|
+
fmt = ps[i...fmt_end]
|
|
244
|
+
result << Time.now.strftime(fmt)
|
|
245
|
+
i = fmt_end
|
|
246
|
+
end
|
|
247
|
+
else
|
|
248
|
+
i -= 1
|
|
249
|
+
result << Time.now.strftime('%y-%m-%d')
|
|
250
|
+
end
|
|
251
|
+
when 'g'
|
|
252
|
+
# %g - git prompt info (branch, status)
|
|
253
|
+
result << git_prompt_info
|
|
254
|
+
when 'p'
|
|
255
|
+
# %p or %p{N} - abbreviated path (like fish's prompt_pwd)
|
|
256
|
+
# N specifies expand_level (default 1)
|
|
257
|
+
expand_level = 1
|
|
258
|
+
if i + 1 < ps.length && ps[i + 1] == '{'
|
|
259
|
+
i += 2
|
|
260
|
+
level_end = ps.index('}', i)
|
|
261
|
+
if level_end
|
|
262
|
+
expand_level = ps[i...level_end].to_i
|
|
263
|
+
expand_level = 1 if expand_level < 1
|
|
264
|
+
i = level_end
|
|
265
|
+
else
|
|
266
|
+
i -= 2
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
result << prompt_pwd(expand_level: expand_level)
|
|
270
|
+
when 'j'
|
|
271
|
+
result << JobManager.instance.active.count.to_s
|
|
272
|
+
when '?'
|
|
273
|
+
result << @last_status.to_s
|
|
274
|
+
when '#'
|
|
275
|
+
result << (Process.uid == 0 ? '#' : '%')
|
|
276
|
+
when 'F'
|
|
277
|
+
# %F{color} - foreground color
|
|
278
|
+
i += 1
|
|
279
|
+
if i < ps.length && ps[i] == '{'
|
|
280
|
+
i += 1
|
|
281
|
+
color_end = ps.index('}', i)
|
|
282
|
+
if color_end
|
|
283
|
+
color = ps[i...color_end]
|
|
284
|
+
result << zsh_color_to_ansi(color, :fg)
|
|
285
|
+
i = color_end
|
|
286
|
+
end
|
|
287
|
+
else
|
|
288
|
+
i -= 1
|
|
289
|
+
end
|
|
290
|
+
when 'f'
|
|
291
|
+
result << "\e[39m" # Reset foreground
|
|
292
|
+
when 'K'
|
|
293
|
+
# %K{color} - background color
|
|
294
|
+
i += 1
|
|
295
|
+
if i < ps.length && ps[i] == '{'
|
|
296
|
+
i += 1
|
|
297
|
+
color_end = ps.index('}', i)
|
|
298
|
+
if color_end
|
|
299
|
+
color = ps[i...color_end]
|
|
300
|
+
result << zsh_color_to_ansi(color, :bg)
|
|
301
|
+
i = color_end
|
|
302
|
+
end
|
|
303
|
+
else
|
|
304
|
+
i -= 1
|
|
305
|
+
end
|
|
306
|
+
when 'k'
|
|
307
|
+
result << "\e[49m" # Reset background
|
|
308
|
+
when 'B'
|
|
309
|
+
result << "\e[1m" # Bold on
|
|
310
|
+
when 'b'
|
|
311
|
+
result << "\e[22m" # Bold off
|
|
312
|
+
when 'U'
|
|
313
|
+
result << "\e[4m" # Underline on
|
|
314
|
+
when 'u'
|
|
315
|
+
result << "\e[24m" # Underline off
|
|
316
|
+
when '%'
|
|
317
|
+
result << '%'
|
|
318
|
+
else
|
|
319
|
+
# Unknown escape, keep literal
|
|
320
|
+
result << '%' << ps[i]
|
|
321
|
+
end
|
|
322
|
+
i += 1
|
|
323
|
+
else
|
|
324
|
+
result << ps[i]
|
|
325
|
+
i += 1
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# promptvars (bash) / prompt_subst (zsh): if enabled, perform variable and command substitution
|
|
330
|
+
if Builtins.shopt_enabled?('promptvars') || Builtins.zsh_option_enabled?('prompt_subst')
|
|
331
|
+
result = expand_string_content(result)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
result
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Convert zsh color names/numbers to ANSI escape codes
|
|
338
|
+
# Supports: color names (red, green, blue, etc.), 0-255 color numbers
|
|
339
|
+
def zsh_color_to_ansi(color, type)
|
|
340
|
+
base = type == :fg ? 30 : 40
|
|
341
|
+
|
|
342
|
+
if color =~ /\A\d+\z/
|
|
343
|
+
num = color.to_i
|
|
344
|
+
if num < 8
|
|
345
|
+
"\e[#{base + num}m"
|
|
346
|
+
elsif num < 16
|
|
347
|
+
# Bright colors (8-15)
|
|
348
|
+
"\e[#{base + 60 + (num - 8)}m"
|
|
349
|
+
else
|
|
350
|
+
# 256-color mode
|
|
351
|
+
"\e[#{type == :fg ? 38 : 48};5;#{num}m"
|
|
352
|
+
end
|
|
353
|
+
elsif ZSH_COLORS.key?(color.downcase)
|
|
354
|
+
"\e[#{base + ZSH_COLORS[color.downcase]}m"
|
|
355
|
+
else
|
|
356
|
+
''
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Abbreviated path for prompts (like fish's prompt_pwd)
|
|
361
|
+
# expand_level: number of trailing path components to show in full
|
|
362
|
+
# Example with ~/src/github.com/amatsuda/rubish:
|
|
363
|
+
# prompt_pwd(expand_level: 1) => "~/s/g/a/rubish"
|
|
364
|
+
# prompt_pwd(expand_level: 2) => "~/s/g/amatsuda/rubish"
|
|
365
|
+
def prompt_pwd(expand_level: 1)
|
|
366
|
+
home = ENV['HOME'] || ''
|
|
367
|
+
cwd = Dir.pwd
|
|
368
|
+
path = home.empty? ? cwd : cwd.sub(/\A#{Regexp.escape(home)}/, '~')
|
|
369
|
+
|
|
370
|
+
components = path.split('/')
|
|
371
|
+
return path if components.length <= expand_level + 1
|
|
372
|
+
|
|
373
|
+
# Handle leading empty string from absolute path or ~
|
|
374
|
+
first = components.first
|
|
375
|
+
rest = components[1..]
|
|
376
|
+
|
|
377
|
+
# Number of components to abbreviate (all except the last expand_level)
|
|
378
|
+
abbrev_count = rest.length - expand_level
|
|
379
|
+
|
|
380
|
+
abbreviated = rest.take(abbrev_count).map { |c| c[0] || c }
|
|
381
|
+
full = rest.drop(abbrev_count)
|
|
382
|
+
|
|
383
|
+
([first] + abbreviated + full).join('/')
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Color helper methods for prompts
|
|
387
|
+
# These wrap text in ANSI escape codes for colorized output
|
|
388
|
+
#
|
|
389
|
+
# Usage with Rubish.set_prompt:
|
|
390
|
+
# Rubish.set_prompt { cyan(prompt_pwd) + " " + green(git_prompt_info) + "> " }
|
|
391
|
+
|
|
392
|
+
def black(text); "\e[30m#{text}\e[39m"; end
|
|
393
|
+
def red(text); "\e[31m#{text}\e[39m"; end
|
|
394
|
+
def green(text); "\e[32m#{text}\e[39m"; end
|
|
395
|
+
def yellow(text); "\e[33m#{text}\e[39m"; end
|
|
396
|
+
def blue(text); "\e[34m#{text}\e[39m"; end
|
|
397
|
+
def magenta(text); "\e[35m#{text}\e[39m"; end
|
|
398
|
+
def cyan(text); "\e[36m#{text}\e[39m"; end
|
|
399
|
+
def white(text); "\e[37m#{text}\e[39m"; end
|
|
400
|
+
|
|
401
|
+
# Bright/bold colors
|
|
402
|
+
def bright_black(text); "\e[90m#{text}\e[39m"; end
|
|
403
|
+
def bright_red(text); "\e[91m#{text}\e[39m"; end
|
|
404
|
+
def bright_green(text); "\e[92m#{text}\e[39m"; end
|
|
405
|
+
def bright_yellow(text); "\e[93m#{text}\e[39m"; end
|
|
406
|
+
def bright_blue(text); "\e[94m#{text}\e[39m"; end
|
|
407
|
+
def bright_magenta(text); "\e[95m#{text}\e[39m"; end
|
|
408
|
+
def bright_cyan(text); "\e[96m#{text}\e[39m"; end
|
|
409
|
+
def bright_white(text); "\e[97m#{text}\e[39m"; end
|
|
410
|
+
|
|
411
|
+
# Text styles
|
|
412
|
+
def bold(text); "\e[1m#{text}\e[22m"; end
|
|
413
|
+
def dim(text); "\e[2m#{text}\e[22m"; end
|
|
414
|
+
def italic(text); "\e[3m#{text}\e[23m"; end
|
|
415
|
+
def underline(text); "\e[4m#{text}\e[24m"; end
|
|
416
|
+
|
|
417
|
+
# Flexible color by name or number (0-255)
|
|
418
|
+
def fg(color, text)
|
|
419
|
+
code = color_to_code(color, :fg)
|
|
420
|
+
"#{code}#{text}\e[39m"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def bg(color, text)
|
|
424
|
+
code = color_to_code(color, :bg)
|
|
425
|
+
"#{code}#{text}\e[49m"
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Prompt helper methods - Ruby equivalents of % escape sequences
|
|
429
|
+
# These can be used in Rubish.set_prompt / Rubish.set_right_prompt blocks
|
|
430
|
+
|
|
431
|
+
# %n - current username
|
|
432
|
+
def current_username
|
|
433
|
+
ENV['USER'] || Etc.getlogin || 'user'
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# %m - short hostname (first component)
|
|
437
|
+
def short_hostname
|
|
438
|
+
Socket.gethostname.split('.').first
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# %M - full hostname
|
|
442
|
+
def full_hostname
|
|
443
|
+
Socket.gethostname
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# %~ - current directory with ~ substitution for home
|
|
447
|
+
def current_directory
|
|
448
|
+
home = ENV['HOME'] || ''
|
|
449
|
+
cwd = Dir.pwd
|
|
450
|
+
home.empty? ? cwd : cwd.sub(/\A#{Regexp.escape(home)}/, '~')
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# %/ or %d - full current directory (no ~ substitution)
|
|
454
|
+
def full_directory
|
|
455
|
+
Dir.pwd
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# %. - basename of current directory (~ if in home)
|
|
459
|
+
def directory_basename
|
|
460
|
+
cwd = Dir.pwd
|
|
461
|
+
home = ENV['HOME'] || ''
|
|
462
|
+
cwd == home ? '~' : File.basename(cwd)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# %j - number of background jobs
|
|
466
|
+
def job_count
|
|
467
|
+
JobManager.instance.active.count
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# %? - exit status of last command
|
|
471
|
+
def last_exit_status
|
|
472
|
+
@last_status
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# %# - privilege indicator (# for root, % for normal user)
|
|
476
|
+
def privilege_indicator
|
|
477
|
+
Process.uid == 0 ? '#' : '%'
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# %! - current history number
|
|
481
|
+
def history_number
|
|
482
|
+
Reline::HISTORY.length + 1
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# %i or \# - command number in this session
|
|
486
|
+
def command_number
|
|
487
|
+
@command_number || 1
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# %D{format} - formatted date/time (strftime)
|
|
491
|
+
def formatted_time(format = '%H:%M:%S')
|
|
492
|
+
Time.now.strftime(format)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# %T - current time in 24-hour HH:MM format
|
|
496
|
+
def time_24h
|
|
497
|
+
Time.now.strftime('%H:%M')
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# %t or %@ - current time in 12-hour with AM/PM
|
|
501
|
+
def time_12h
|
|
502
|
+
Time.now.strftime('%I:%M %p')
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# %* - current time with seconds HH:MM:SS
|
|
506
|
+
def time_with_seconds
|
|
507
|
+
Time.now.strftime('%H:%M:%S')
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# %D - current date in YY-MM-DD format
|
|
511
|
+
def current_date
|
|
512
|
+
Time.now.strftime('%y-%m-%d')
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# %W - day of week (Sunday = 0)
|
|
516
|
+
def day_of_week
|
|
517
|
+
Time.now.wday
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# %l - current tty
|
|
521
|
+
def current_tty
|
|
522
|
+
File.basename(`tty`.strip) rescue 'tty'
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# %s - shell name
|
|
526
|
+
def shell_name
|
|
527
|
+
'rubish'
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# %v - shell version
|
|
531
|
+
def shell_version
|
|
532
|
+
Rubish::VERSION
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Helper for superuser check
|
|
536
|
+
def superuser?
|
|
537
|
+
Process.uid == 0
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Git prompt info - returns formatted git status for use in prompts
|
|
541
|
+
#
|
|
542
|
+
# Keyword arguments (default to corresponding GIT_PS1_* env vars if not specified):
|
|
543
|
+
# dirty: show * for unstaged, + for staged changes (GIT_PS1_SHOWDIRTYSTATE)
|
|
544
|
+
# stash: show $ if stash is not empty (GIT_PS1_SHOWSTASHSTATE)
|
|
545
|
+
# untracked: show % if there are untracked files (GIT_PS1_SHOWUNTRACKEDFILES)
|
|
546
|
+
# upstream: show <, >, <>, = for behind/ahead/diverged/up-to-date (GIT_PS1_SHOWUPSTREAM)
|
|
547
|
+
# colorize: colorize the output (GIT_PS1_SHOWCOLORHINTS)
|
|
548
|
+
# describe: how to show detached HEAD: contains, branch, tag, describe, default (GIT_PS1_DESCRIBE_STYLE)
|
|
549
|
+
#
|
|
550
|
+
# Example:
|
|
551
|
+
# git_prompt_info(dirty: true, stash: true, untracked: true, upstream: true, colorize: true)
|
|
552
|
+
def git_prompt_info(dirty: nil, stash: nil, untracked: nil, upstream: nil, colorize: nil, describe: nil)
|
|
553
|
+
# Check if we're in a git repo
|
|
554
|
+
git_dir = `git rev-parse --git-dir 2>/dev/null`.chomp
|
|
555
|
+
return '' if git_dir.empty?
|
|
556
|
+
|
|
557
|
+
# Resolve options: use kwarg if provided, otherwise fall back to env var
|
|
558
|
+
show_dirty = dirty.nil? ? ENV['GIT_PS1_SHOWDIRTYSTATE'] : dirty
|
|
559
|
+
show_stash = stash.nil? ? ENV['GIT_PS1_SHOWSTASHSTATE'] : stash
|
|
560
|
+
show_untracked = untracked.nil? ? ENV['GIT_PS1_SHOWUNTRACKEDFILES'] : untracked
|
|
561
|
+
show_upstream = upstream.nil? ? ENV['GIT_PS1_SHOWUPSTREAM'] : upstream
|
|
562
|
+
show_colorize = colorize.nil? ? ENV['GIT_PS1_SHOWCOLORHINTS'] : colorize
|
|
563
|
+
describe_style = describe || ENV['GIT_PS1_DESCRIBE_STYLE'] || 'default'
|
|
564
|
+
|
|
565
|
+
# Get branch name or commit
|
|
566
|
+
branch = `git symbolic-ref --short HEAD 2>/dev/null`.chomp
|
|
567
|
+
if branch.empty?
|
|
568
|
+
# Detached HEAD - show commit or tag
|
|
569
|
+
branch = case describe_style.to_s
|
|
570
|
+
when 'contains'
|
|
571
|
+
`git describe --contains HEAD 2>/dev/null`.chomp
|
|
572
|
+
when 'branch'
|
|
573
|
+
`git describe --contains --all HEAD 2>/dev/null`.chomp
|
|
574
|
+
when 'tag'
|
|
575
|
+
`git describe --tags HEAD 2>/dev/null`.chomp
|
|
576
|
+
when 'describe'
|
|
577
|
+
`git describe HEAD 2>/dev/null`.chomp
|
|
578
|
+
else
|
|
579
|
+
`git rev-parse --short HEAD 2>/dev/null`.chomp
|
|
580
|
+
end
|
|
581
|
+
branch = "(#{branch})" unless branch.empty?
|
|
582
|
+
end
|
|
583
|
+
return '' if branch.empty?
|
|
584
|
+
|
|
585
|
+
state = +''
|
|
586
|
+
|
|
587
|
+
# Show dirty state (* for unstaged, + for staged)
|
|
588
|
+
if show_dirty
|
|
589
|
+
# Check for staged changes
|
|
590
|
+
staged = !`git diff --cached --quiet 2>/dev/null; echo $?`.chomp.to_i.zero?
|
|
591
|
+
# Check for unstaged changes
|
|
592
|
+
unstaged = !`git diff --quiet 2>/dev/null; echo $?`.chomp.to_i.zero?
|
|
593
|
+
state << '+' if staged
|
|
594
|
+
state << '*' if unstaged
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Show stash state
|
|
598
|
+
if show_stash
|
|
599
|
+
stash_list = `git stash list 2>/dev/null`.chomp
|
|
600
|
+
state << '$' unless stash_list.empty?
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# Show untracked files
|
|
604
|
+
if show_untracked
|
|
605
|
+
untracked_files = `git ls-files --others --exclude-standard 2>/dev/null`.chomp
|
|
606
|
+
state << '%' unless untracked_files.empty?
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Show upstream status
|
|
610
|
+
upstream_str = ''
|
|
611
|
+
if show_upstream
|
|
612
|
+
counts = `git rev-list --left-right --count HEAD...@{upstream} 2>/dev/null`.chomp.split
|
|
613
|
+
if counts.length == 2
|
|
614
|
+
ahead, behind = counts.map(&:to_i)
|
|
615
|
+
if ahead > 0 && behind > 0
|
|
616
|
+
upstream_str = '<>'
|
|
617
|
+
elsif ahead > 0
|
|
618
|
+
upstream_str = '>'
|
|
619
|
+
elsif behind > 0
|
|
620
|
+
upstream_str = '<'
|
|
621
|
+
else
|
|
622
|
+
upstream_str = '='
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Format output
|
|
628
|
+
state_str = state.empty? ? '' : " #{state}"
|
|
629
|
+
|
|
630
|
+
# Apply colors if enabled
|
|
631
|
+
if show_colorize
|
|
632
|
+
# Green for clean, red for dirty, yellow for staged only
|
|
633
|
+
color_branch = if state.include?('*')
|
|
634
|
+
"\e[31m#{branch}\e[0m" # Red for unstaged changes
|
|
635
|
+
elsif state.include?('+')
|
|
636
|
+
"\e[33m#{branch}\e[0m" # Yellow for staged only
|
|
637
|
+
else
|
|
638
|
+
"\e[32m#{branch}\e[0m" # Green for clean
|
|
639
|
+
end
|
|
640
|
+
"(#{color_branch}#{state_str}#{upstream_str})"
|
|
641
|
+
else
|
|
642
|
+
"(#{branch}#{state_str}#{upstream_str})"
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
# PS3 prompt for select command
|
|
647
|
+
def select_prompt
|
|
648
|
+
ps3 = ENV['PS3']
|
|
649
|
+
if ps3
|
|
650
|
+
expand_prompt(ps3)
|
|
651
|
+
else
|
|
652
|
+
'#? '
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# Print PS0 prompt (displayed after command is read, before execution)
|
|
657
|
+
# PS0 supports the same escape sequences as PS1
|
|
658
|
+
def print_ps0
|
|
659
|
+
ps0 = ENV['PS0']
|
|
660
|
+
return unless ps0 && !ps0.empty?
|
|
661
|
+
|
|
662
|
+
print expand_prompt(ps0)
|
|
663
|
+
$stdout.flush
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Execute PROMPT_COMMAND before displaying the prompt
|
|
667
|
+
# PROMPT_COMMAND can be:
|
|
668
|
+
# - A single command string
|
|
669
|
+
# - Multiple commands separated by semicolons
|
|
670
|
+
# - An array of commands (PROMPT_COMMAND[0], PROMPT_COMMAND[1], etc.)
|
|
671
|
+
def run_prompt_command
|
|
672
|
+
# First check for PROMPT_COMMAND array
|
|
673
|
+
prompt_cmds = Builtins.get_array('PROMPT_COMMAND')
|
|
674
|
+
if prompt_cmds && !prompt_cmds.empty?
|
|
675
|
+
prompt_cmds.each do |cmd|
|
|
676
|
+
next if cmd.nil? || cmd.empty?
|
|
677
|
+
execute_prompt_command(cmd)
|
|
678
|
+
end
|
|
679
|
+
return
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Fall back to PROMPT_COMMAND as a string
|
|
683
|
+
cmd = ENV['PROMPT_COMMAND']
|
|
684
|
+
return if cmd.nil? || cmd.empty?
|
|
685
|
+
|
|
686
|
+
execute_prompt_command(cmd)
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
def execute_prompt_command(cmd)
|
|
690
|
+
# Save current state
|
|
691
|
+
saved_status = @last_status
|
|
692
|
+
|
|
693
|
+
# Execute the command silently (don't affect $?)
|
|
694
|
+
begin
|
|
695
|
+
execute(cmd)
|
|
696
|
+
rescue => e
|
|
697
|
+
# Silently ignore errors in PROMPT_COMMAND
|
|
698
|
+
$stderr.puts "rubish: PROMPT_COMMAND: #{e.message}" if Builtins.set_option?('x')
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
# Restore the exit status (PROMPT_COMMAND shouldn't affect $?)
|
|
702
|
+
@last_status = saved_status
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# Trim directory path according to PROMPT_DIRTRIM
|
|
706
|
+
# When PROMPT_DIRTRIM is set to N, only show last N directory components
|
|
707
|
+
# with leading "..." to indicate trimming
|
|
708
|
+
def trim_prompt_dir(path)
|
|
709
|
+
dirtrim = ENV['PROMPT_DIRTRIM']
|
|
710
|
+
return path if dirtrim.nil? || dirtrim.empty?
|
|
711
|
+
|
|
712
|
+
trim_count = dirtrim.to_i
|
|
713
|
+
return path if trim_count <= 0
|
|
714
|
+
|
|
715
|
+
# Handle ~ prefix specially
|
|
716
|
+
if path.start_with?('~')
|
|
717
|
+
if path == '~'
|
|
718
|
+
return path
|
|
719
|
+
end
|
|
720
|
+
# Remove ~ prefix, process the rest
|
|
721
|
+
rest = path[1..] # includes leading /
|
|
722
|
+
rest = rest[1..] if rest.start_with?('/') # remove leading /
|
|
723
|
+
components = rest.split('/')
|
|
724
|
+
if components.length <= trim_count
|
|
725
|
+
return path
|
|
726
|
+
end
|
|
727
|
+
trimmed = components.last(trim_count).join('/')
|
|
728
|
+
return '~/.../' + trimmed
|
|
729
|
+
else
|
|
730
|
+
# Absolute or relative path
|
|
731
|
+
components = path.split('/').reject(&:empty?)
|
|
732
|
+
if components.length <= trim_count
|
|
733
|
+
return path
|
|
734
|
+
end
|
|
735
|
+
trimmed = components.last(trim_count).join('/')
|
|
736
|
+
return '.../' + trimmed
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
private
|
|
741
|
+
|
|
742
|
+
def color_to_code(color, type)
|
|
743
|
+
base = type == :fg ? 30 : 40
|
|
744
|
+
|
|
745
|
+
case color
|
|
746
|
+
when Integer
|
|
747
|
+
if color < 8
|
|
748
|
+
"\e[#{base + color}m"
|
|
749
|
+
elsif color < 16
|
|
750
|
+
"\e[#{base + 60 + (color - 8)}m"
|
|
751
|
+
else
|
|
752
|
+
"\e[#{type == :fg ? 38 : 48};5;#{color}m"
|
|
753
|
+
end
|
|
754
|
+
when Symbol, String
|
|
755
|
+
name = color.to_s.downcase
|
|
756
|
+
if ZSH_COLORS.key?(name)
|
|
757
|
+
"\e[#{base + ZSH_COLORS[name]}m"
|
|
758
|
+
else
|
|
759
|
+
''
|
|
760
|
+
end
|
|
761
|
+
else
|
|
762
|
+
''
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
end
|
|
766
|
+
end
|