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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.dockerignore +23 -0
  3. data/Dockerfile +54 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +39 -0
  6. data/Rakefile +12 -0
  7. data/lib/rubish/arithmetic.rb +140 -0
  8. data/lib/rubish/ast.rb +168 -0
  9. data/lib/rubish/builtins/arithmetic.rb +129 -0
  10. data/lib/rubish/builtins/bind_readline.rb +834 -0
  11. data/lib/rubish/builtins/directory_stack.rb +182 -0
  12. data/lib/rubish/builtins/echo_printf.rb +510 -0
  13. data/lib/rubish/builtins/hash_directories.rb +260 -0
  14. data/lib/rubish/builtins/read.rb +299 -0
  15. data/lib/rubish/builtins/trap.rb +324 -0
  16. data/lib/rubish/codegen.rb +1273 -0
  17. data/lib/rubish/completion.rb +840 -0
  18. data/lib/rubish/completions/bash_helpers.rb +530 -0
  19. data/lib/rubish/completions/git.rb +431 -0
  20. data/lib/rubish/completions/help_parser.rb +453 -0
  21. data/lib/rubish/completions/ssh.rb +114 -0
  22. data/lib/rubish/config.rb +267 -0
  23. data/lib/rubish/data/builtin_help.rb +716 -0
  24. data/lib/rubish/data/completion_data.rb +53 -0
  25. data/lib/rubish/data/readline_config.rb +47 -0
  26. data/lib/rubish/data/shell_options.rb +251 -0
  27. data/lib/rubish/data_define.rb +65 -0
  28. data/lib/rubish/execution_context.rb +1124 -0
  29. data/lib/rubish/expansion.rb +988 -0
  30. data/lib/rubish/history.rb +663 -0
  31. data/lib/rubish/lazy_loader.rb +127 -0
  32. data/lib/rubish/lexer.rb +1194 -0
  33. data/lib/rubish/parser.rb +1167 -0
  34. data/lib/rubish/prompt.rb +766 -0
  35. data/lib/rubish/repl.rb +2267 -0
  36. data/lib/rubish/runtime/builtins.rb +7222 -0
  37. data/lib/rubish/runtime/command.rb +1153 -0
  38. data/lib/rubish/runtime/job.rb +153 -0
  39. data/lib/rubish/runtime.rb +1169 -0
  40. data/lib/rubish/shell_state.rb +241 -0
  41. data/lib/rubish/startup_profiler.rb +67 -0
  42. data/lib/rubish/version.rb +5 -0
  43. data/lib/rubish.rb +60 -0
  44. data/sig/rubish.rbs +4 -0
  45. 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