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,834 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubish
4
+ module Builtins
5
+ # READLINE_LINE - contents of the readline buffer during bind -x execution
6
+ def readline_line
7
+ @state.readline_line_getter&.call || ''
8
+ end
9
+
10
+ def readline_line=(value)
11
+ @state.readline_line_setter&.call(value.to_s)
12
+ end
13
+
14
+ # READLINE_POINT - cursor position (index) in READLINE_LINE during bind -x execution
15
+ def readline_point
16
+ @state.readline_point_getter&.call || 0
17
+ end
18
+
19
+ def readline_point=(value)
20
+ @state.readline_point_setter&.call(value.to_i)
21
+ @state.readline_point_modified = true
22
+ end
23
+
24
+ # READLINE_MARK - mark position in READLINE_LINE during bind -x execution
25
+ def readline_mark
26
+ @state.readline_mark_getter&.call || 0
27
+ end
28
+
29
+ def readline_mark=(value)
30
+ @state.readline_mark_setter&.call(value.to_i)
31
+ end
32
+
33
+ # Track if READLINE_POINT was explicitly modified during bind -x
34
+ def readline_point_modified
35
+ @state.readline_point_modified
36
+ end
37
+
38
+ def readline_point_modified=(value)
39
+ @state.readline_point_modified = value
40
+ end
41
+
42
+ # Executor for bind -x commands
43
+ def bind_x_executor
44
+ @state.bind_x_executor
45
+ end
46
+
47
+ def bind_x_executor=(value)
48
+ @state.bind_x_executor = value
49
+ end
50
+
51
+ def bind(args)
52
+ # bind [-m keymap] [-lpsvPSVX]
53
+ # bind [-m keymap] [-q function] [-u function] [-r keyseq]
54
+ # bind [-m keymap] -f filename
55
+ # bind [-m keymap] -x keyseq:shell-command
56
+ # bind [-m keymap] keyseq:function-name
57
+ # bind "set variable value"
58
+
59
+ keymap = 'emacs' # default keymap
60
+ list_functions = false
61
+ print_bindings = false
62
+ print_bindings_readable = false
63
+ print_macros = false
64
+ print_macros_readable = false
65
+ print_variables = false
66
+ print_variables_readable = false
67
+ print_shell_bindings = false
68
+ query_function = nil
69
+ unbind_function = nil
70
+ remove_keyseq = nil
71
+ read_file = nil
72
+ shell_command_binding = nil
73
+ bindings_to_add = []
74
+ variable_settings = []
75
+
76
+ i = 0
77
+ while i < args.length
78
+ arg = args[i]
79
+
80
+ if arg.start_with?('-') && !arg.include?(':')
81
+ case arg
82
+ when '-m'
83
+ i += 1
84
+ keymap = args[i] if args[i]
85
+ when '-l'
86
+ list_functions = true
87
+ when '-p'
88
+ print_bindings_readable = true
89
+ when '-P'
90
+ print_bindings = true
91
+ when '-s'
92
+ print_macros_readable = true
93
+ when '-S'
94
+ print_macros = true
95
+ when '-v'
96
+ print_variables_readable = true
97
+ when '-V'
98
+ print_variables = true
99
+ when '-X'
100
+ print_shell_bindings = true
101
+ when '-q'
102
+ i += 1
103
+ query_function = args[i]
104
+ when '-u'
105
+ i += 1
106
+ unbind_function = args[i]
107
+ when '-r'
108
+ i += 1
109
+ remove_keyseq = args[i]
110
+ when '-f'
111
+ i += 1
112
+ read_file = args[i]
113
+ when '-x'
114
+ i += 1
115
+ shell_command_binding = args[i]
116
+ else
117
+ # Handle combined flags
118
+ arg[1..].each_char do |c|
119
+ case c
120
+ when 'l' then list_functions = true
121
+ when 'p' then print_bindings_readable = true
122
+ when 'P' then print_bindings = true
123
+ when 's' then print_macros_readable = true
124
+ when 'S' then print_macros = true
125
+ when 'v' then print_variables_readable = true
126
+ when 'V' then print_variables = true
127
+ when 'X' then print_shell_bindings = true
128
+ else
129
+ puts "bind: -#{c}: invalid option"
130
+ return false
131
+ end
132
+ end
133
+ end
134
+ elsif arg.start_with?('set ')
135
+ # Variable setting: set variable value
136
+ variable_settings << arg
137
+ elsif arg.include?(':')
138
+ # keyseq:function-name or keyseq:macro
139
+ bindings_to_add << arg
140
+ else
141
+ puts "bind: #{arg}: invalid key binding"
142
+ return false
143
+ end
144
+ i += 1
145
+ end
146
+
147
+ # List all readline function names
148
+ if list_functions
149
+ READLINE_FUNCTIONS.each { |f| puts f }
150
+ return true
151
+ end
152
+
153
+ # Print key bindings in reusable format
154
+ if print_bindings_readable
155
+ # First show Reline's actual key bindings
156
+ get_reline_key_bindings.each do |keyseq, action|
157
+ puts "\"#{escape_keyseq(keyseq)}\": #{action}"
158
+ end
159
+ # Then show any additional bindings from @key_bindings
160
+ @state.key_bindings.each do |keyseq, binding|
161
+ next if binding[:type] == :macro || binding[:type] == :command
162
+
163
+ puts "\"#{escape_keyseq(keyseq)}\": #{binding[:value]}"
164
+ end
165
+ return true
166
+ end
167
+
168
+ # Print key bindings with function names
169
+ if print_bindings
170
+ # First show Reline's actual key bindings
171
+ get_reline_key_bindings.each do |keyseq, action|
172
+ puts "#{escape_keyseq(keyseq)} can be found in #{action}."
173
+ end
174
+ # Then show any additional bindings from @key_bindings
175
+ @state.key_bindings.each do |keyseq, binding|
176
+ next if binding[:type] == :macro || binding[:type] == :command
177
+
178
+ puts "#{escape_keyseq(keyseq)} can be found in #{binding[:value]}."
179
+ end
180
+ return true
181
+ end
182
+
183
+ # Print macros in reusable format
184
+ if print_macros_readable
185
+ @state.key_bindings.each do |keyseq, binding|
186
+ next unless binding[:type] == :macro
187
+
188
+ puts "\"#{escape_keyseq(keyseq)}\": \"#{binding[:value]}\""
189
+ end
190
+ return true
191
+ end
192
+
193
+ # Print macros
194
+ if print_macros
195
+ @state.key_bindings.each do |keyseq, binding|
196
+ next unless binding[:type] == :macro
197
+
198
+ puts "#{escape_keyseq(keyseq)} outputs #{binding[:value]}"
199
+ end
200
+ return true
201
+ end
202
+
203
+ # Print readline variables in reusable format
204
+ if print_variables_readable
205
+ READLINE_VARIABLES_LIST.each do |var|
206
+ value = get_readline_variable(var) || 'off'
207
+ puts "set #{var} #{value}"
208
+ end
209
+ return true
210
+ end
211
+
212
+ # Print readline variables
213
+ if print_variables
214
+ READLINE_VARIABLES_LIST.each do |var|
215
+ value = get_readline_variable(var) || 'off'
216
+ puts "#{var} is set to `#{value}'"
217
+ end
218
+ return true
219
+ end
220
+
221
+ # Print shell command bindings
222
+ if print_shell_bindings
223
+ @state.key_bindings.each do |keyseq, binding|
224
+ next unless binding[:type] == :command
225
+
226
+ puts "\"#{escape_keyseq(keyseq)}\": \"#{binding[:value]}\""
227
+ end
228
+ return true
229
+ end
230
+
231
+ # Query which keys invoke a function
232
+ if query_function
233
+ found = false
234
+ @state.key_bindings.each do |keyseq, binding|
235
+ if binding[:value] == query_function && binding[:type] == :function
236
+ puts "#{query_function} can be invoked via \"#{escape_keyseq(keyseq)}\"."
237
+ found = true
238
+ end
239
+ end
240
+ puts "#{query_function} is not bound to any keys." unless found
241
+ return true
242
+ end
243
+
244
+ # Unbind all keys for a function
245
+ if unbind_function
246
+ @state.key_bindings.delete_if { |_, binding| binding[:value] == unbind_function }
247
+ return true
248
+ end
249
+
250
+ # Remove binding for keyseq
251
+ if remove_keyseq
252
+ @state.key_bindings.delete(remove_keyseq)
253
+ return true
254
+ end
255
+
256
+ # Read bindings from file
257
+ if read_file
258
+ unless File.exist?(read_file)
259
+ puts "bind: #{read_file}: cannot read: No such file or directory"
260
+ return false
261
+ end
262
+
263
+ File.readlines(read_file).each do |line|
264
+ line = line.strip
265
+ next if line.empty? || line.start_with?('#')
266
+
267
+ # Skip conditional directives ($if, $else, $endif, $include)
268
+ next if line.start_with?('$')
269
+
270
+ if line.start_with?('set ')
271
+ # Variable setting: set variable value
272
+ parts = line.split(/\s+/, 3)
273
+ if parts.length >= 3
274
+ apply_readline_variable(parts[1], parts[2])
275
+ end
276
+ elsif line.include?(':')
277
+ parse_and_add_binding(line, keymap)
278
+ end
279
+ end
280
+ return true
281
+ end
282
+
283
+ # Add shell command binding
284
+ if shell_command_binding
285
+ if shell_command_binding.include?(':')
286
+ keyseq, command = shell_command_binding.split(':', 2)
287
+ keyseq = unescape_keyseq(keyseq.delete('"'))
288
+ command = command.delete('"').strip
289
+ @state.key_bindings[keyseq] = {type: :command, value: command, keymap: keymap}
290
+ # Register with Reline for actual execution
291
+ register_bind_x_with_reline(keyseq, command, keymap)
292
+ else
293
+ puts "bind: #{shell_command_binding}: invalid key binding"
294
+ return false
295
+ end
296
+ return true
297
+ end
298
+
299
+ # Add bindings from arguments
300
+ bindings_to_add.each do |binding|
301
+ parse_and_add_binding(binding, keymap)
302
+ end
303
+
304
+ # Process variable settings: "set variable value"
305
+ variable_settings.each do |setting|
306
+ parts = setting.split(/\s+/, 3)
307
+ if parts.length >= 3 && parts[0] == 'set'
308
+ apply_readline_variable(parts[1], parts[2])
309
+ end
310
+ end
311
+
312
+ true
313
+ end
314
+
315
+ def parse_and_add_binding(binding_str, keymap = 'emacs')
316
+ keyseq, value = binding_str.split(':', 2)
317
+ return unless keyseq && value
318
+
319
+ keyseq = unescape_keyseq(keyseq.delete('"').strip)
320
+ value = value.strip
321
+
322
+ # Determine if it's a function or macro
323
+ if value.start_with?('"') && value.end_with?('"')
324
+ # Macro
325
+ @state.key_bindings[keyseq] = {type: :macro, value: value[1..-2], keymap: keymap}
326
+ else
327
+ # Function
328
+ @state.key_bindings[keyseq] = {type: :function, value: value, keymap: keymap}
329
+ end
330
+ end
331
+
332
+ # Get Reline's actual key bindings as a hash of keyseq string => action symbol
333
+ # Combines bindings from both @additional_key_bindings (higher priority, from inputrc/custom)
334
+ # and @default_key_bindings (lower priority, built-in defaults)
335
+ def get_reline_key_bindings
336
+ result = {}
337
+ begin
338
+ config = Reline.core.config
339
+ editing_mode = config.instance_variable_get(:@editing_mode_label) || :emacs
340
+
341
+ # First, get default key bindings (lowest priority)
342
+ default_bindings = config.instance_variable_get(:@default_key_bindings)
343
+ if default_bindings && default_bindings[editing_mode]
344
+ bindings = default_bindings[editing_mode].instance_variable_get(:@key_bindings)
345
+ bindings&.each do |seq, action|
346
+ next if action == :ed_insert || action == :ed_digit
347
+ keyseq = seq.pack('C*')
348
+ result[keyseq] = action
349
+ end
350
+ end
351
+
352
+ # Then, get additional key bindings (higher priority, will override defaults)
353
+ additional_bindings = config.instance_variable_get(:@additional_key_bindings)
354
+ if additional_bindings && additional_bindings[editing_mode]
355
+ bindings = additional_bindings[editing_mode].instance_variable_get(:@key_bindings)
356
+ bindings&.each do |seq, action|
357
+ next if action == :ed_insert || action == :ed_digit
358
+ keyseq = seq.pack('C*')
359
+ result[keyseq] = action
360
+ end
361
+ end
362
+ rescue StandardError
363
+ # Reline not available or error accessing bindings
364
+ end
365
+ result
366
+ end
367
+
368
+ def escape_keyseq(keyseq)
369
+ result = +''
370
+ keyseq.each_char do |c|
371
+ case c.ord
372
+ when 0x00..0x1F
373
+ if c == "\t"
374
+ result << '\\t'
375
+ elsif c == "\n"
376
+ result << '\\n'
377
+ elsif c == "\r"
378
+ result << '\\r'
379
+ elsif c == "\e"
380
+ result << '\\e'
381
+ else
382
+ # Control character: display as \C-x
383
+ result << "\\C-#{(c.ord + 'a'.ord - 1).chr}"
384
+ end
385
+ when 0x7F
386
+ result << '\\C-?'
387
+ when 0x80..0x9F
388
+ # Meta control character
389
+ result << "\\M-\\C-#{(c.ord - 0x80 + 'a'.ord - 1).chr}"
390
+ when 0xA0..0xFF
391
+ # Meta character
392
+ result << "\\M-#{(c.ord - 0x80).chr}"
393
+ else
394
+ result << c
395
+ end
396
+ end
397
+ result
398
+ end
399
+
400
+ def unescape_keyseq(keyseq)
401
+ result = keyseq.dup
402
+
403
+ # Handle meta escape sequences first (\M-x)
404
+ result.gsub!(/\\M-\\C-([a-zA-Z@\[\]\\^_?])/) do |_|
405
+ char = ::Regexp.last_match(1)
406
+ if char == '?'
407
+ (0x80 | 0x7F).chr # Meta-DEL
408
+ else
409
+ (0x80 | (char.upcase.ord & 0x1F)).chr
410
+ end
411
+ end
412
+
413
+ result.gsub!(/\\M-([^\s])/) do |_|
414
+ char = ::Regexp.last_match(1)
415
+ (0x80 | char.ord).chr
416
+ end
417
+
418
+ # Handle escape sequences
419
+ result.gsub!('\\e', "\e")
420
+ result.gsub!('\\E', "\e") # Both \e and \E mean escape
421
+ result.gsub!('\\t', "\t")
422
+ result.gsub!('\\n', "\n")
423
+ result.gsub!('\\r', "\r")
424
+ result.gsub!('\\a', "\a") # Bell
425
+ result.gsub!('\\b', "\b") # Backspace
426
+ result.gsub!('\\f', "\f") # Form feed
427
+ result.gsub!('\\v', "\v") # Vertical tab
428
+ result.gsub!('\\\\', '\\') # Literal backslash
429
+ result.gsub!('\\"', '"') # Literal quote
430
+ result.gsub!("\\'", "'") # Literal single quote
431
+
432
+ # Handle octal escape sequences \nnn
433
+ result.gsub!(/\\([0-7]{1,3})/) do |_|
434
+ ::Regexp.last_match(1).to_i(8).chr
435
+ end
436
+
437
+ # Handle hex escape sequences \xNN
438
+ result.gsub!(/\\x([0-9a-fA-F]{1,2})/) do |_|
439
+ ::Regexp.last_match(1).to_i(16).chr
440
+ end
441
+
442
+ # Handle control characters \C-x format
443
+ result.gsub!(/\\C-([a-zA-Z@\[\]\\^_])/) do |_|
444
+ char = ::Regexp.last_match(1)
445
+ (char.upcase.ord & 0x1F).chr
446
+ end
447
+
448
+ # Handle control characters \C-? format for DEL
449
+ result.gsub!('\\C-?', "\x7F")
450
+
451
+ # Handle ^x control character format
452
+ result.gsub!(/\^([a-zA-Z@\[\]\\^_?])/) do |_|
453
+ char = ::Regexp.last_match(1)
454
+ if char == '?'
455
+ "\x7F" # DEL
456
+ else
457
+ (char.upcase.ord & 0x1F).chr
458
+ end
459
+ end
460
+
461
+ result
462
+ end
463
+
464
+ def get_key_binding(keyseq)
465
+ @state.key_bindings[keyseq]
466
+ end
467
+
468
+ def clear_key_bindings
469
+ @state.key_bindings.clear
470
+ @state.readline_variables.clear
471
+ end
472
+
473
+ # Register a bind -x shell command with Reline for actual execution
474
+ def register_bind_x_with_reline(keyseq, command, keymap)
475
+ return unless defined?(Reline)
476
+
477
+ # Generate a unique method name for this binding
478
+ method_name = :"__rubish_bind_x_#{@state.bind_x_counter}"
479
+ @state.bind_x_counter += 1
480
+
481
+ # Store the command in the binding for lookup
482
+ @state.key_bindings[keyseq][:method_name] = method_name
483
+
484
+ # Define the method on Reline::LineEditor
485
+ # We need to capture 'command' and 'self' (Builtins) in the closure
486
+ builtins = self
487
+ Reline::LineEditor.define_method(method_name) do |key = nil, **kwargs|
488
+ # Get current line and cursor position from the line editor
489
+ current_line = whole_buffer
490
+ current_point = byte_pointer
491
+
492
+ # Set READLINE_LINE, READLINE_POINT, READLINE_MARK
493
+ builtins.readline_line = current_line
494
+ builtins.readline_point = current_point
495
+ builtins.readline_mark = 0
496
+
497
+ # Track if READLINE_POINT is explicitly modified
498
+ builtins.readline_point_modified = false
499
+
500
+ # Execute the shell command
501
+ if builtins.bind_x_executor
502
+ begin
503
+ builtins.bind_x_executor.call(command)
504
+ rescue => e
505
+ $stderr.puts "bind -x: #{e.message}" if ENV['RUBISH_DEBUG']
506
+ end
507
+ end
508
+
509
+ # Check if READLINE_LINE was modified
510
+ new_line = builtins.readline_line || current_line
511
+ new_point = builtins.readline_point
512
+ point_was_modified = builtins.readline_point_modified
513
+
514
+ # Update the line buffer if it changed
515
+ if new_line != current_line
516
+ # Clear and replace the buffer
517
+ @buffer_of_lines = new_line.split("\n", -1)
518
+ @buffer_of_lines = [''] if @buffer_of_lines.empty?
519
+ @line_index = @buffer_of_lines.length - 1
520
+ end
521
+
522
+ # Update cursor position
523
+ if point_was_modified
524
+ # READLINE_POINT was explicitly set - use it
525
+ self.byte_pointer = [new_point, new_line.bytesize].min
526
+ elsif new_line != current_line
527
+ # Line changed but point not explicitly set - move to end
528
+ self.byte_pointer = new_line.bytesize
529
+ end
530
+ end
531
+
532
+ # Convert keymap name to Reline keymap symbol
533
+ reline_keymap = case keymap
534
+ when 'vi', 'vi-command' then :vi_command
535
+ when 'vi-insert' then :vi_insert
536
+ else :emacs
537
+ end
538
+
539
+ # Register the key binding with Reline
540
+ keystroke = keyseq.bytes.to_a
541
+ Reline.core.config.add_default_key_binding_by_keymap(reline_keymap, keystroke, method_name)
542
+ end
543
+
544
+ # Apply a readline variable to Reline (if applicable)
545
+ def apply_readline_variable(var, value)
546
+ @state.readline_variables[var] = value
547
+
548
+ # Sync with Reline where possible
549
+ begin
550
+ case var
551
+ when 'editing-mode'
552
+ if value == 'vi'
553
+ Reline.vi_editing_mode if defined?(Reline)
554
+ else
555
+ Reline.emacs_editing_mode if defined?(Reline)
556
+ end
557
+ when 'completion-ignore-case'
558
+ if defined?(Reline)
559
+ Reline.completion_case_fold = (value == 'on')
560
+ end
561
+ when 'horizontal-scroll-mode'
562
+ # Reline doesn't support this, but we store it
563
+ when 'mark-directories'
564
+ # Reline doesn't directly support, but completion can check this
565
+ when 'show-all-if-ambiguous'
566
+ # Could be implemented in completion_proc
567
+ when 'bell-style'
568
+ # Reline doesn't expose bell control
569
+ end
570
+ rescue => e
571
+ $stderr.puts "bind: warning: #{e.message}" if ENV['RUBISH_DEBUG']
572
+ end
573
+ end
574
+
575
+ # Get a readline variable value
576
+ def get_readline_variable(var)
577
+ # Check Reline state first for live values
578
+ begin
579
+ case var
580
+ when 'editing-mode'
581
+ if defined?(Reline)
582
+ return Reline.vi_editing_mode? ? 'vi' : 'emacs'
583
+ end
584
+ when 'completion-ignore-case'
585
+ if defined?(Reline)
586
+ return Reline.completion_case_fold ? 'on' : 'off'
587
+ end
588
+ end
589
+ rescue
590
+ # Fall through to stored value
591
+ end
592
+ @state.readline_variables[var]
593
+ end
594
+
595
+ def bindkey(args)
596
+ keymap = nil
597
+ list_keymaps = false
598
+ remove_key = nil
599
+ macro_binding = false
600
+ i = 0
601
+
602
+ while i < args.length
603
+ arg = args[i]
604
+
605
+ if arg.start_with?('-')
606
+ case arg
607
+ when '-l'
608
+ list_keymaps = true
609
+ when '-L'
610
+ # List in bindkey command format (same as no args for now)
611
+ return list_bindkey_bindings(keymap)
612
+ when '-M'
613
+ i += 1
614
+ keymap = args[i] if args[i]
615
+ when '-e'
616
+ # Select emacs keymap
617
+ select_keymap('emacs')
618
+ return true
619
+ when '-v'
620
+ # Select viins keymap
621
+ select_keymap('viins')
622
+ return true
623
+ when '-a'
624
+ # Select vicmd keymap
625
+ select_keymap('vicmd')
626
+ return true
627
+ when '-r'
628
+ i += 1
629
+ remove_key = args[i] if args[i]
630
+ when '-s'
631
+ macro_binding = true
632
+ else
633
+ $stderr.puts "bindkey: bad option: #{arg}"
634
+ return false
635
+ end
636
+ else
637
+ # Non-option argument
638
+ break
639
+ end
640
+ i += 1
641
+ end
642
+
643
+ # List keymap names
644
+ if list_keymaps
645
+ puts 'emacs'
646
+ puts 'viins'
647
+ puts 'vicmd'
648
+ puts 'visual'
649
+ puts 'isearch'
650
+ puts 'command'
651
+ puts 'main'
652
+ return true
653
+ end
654
+
655
+ # Remove binding
656
+ if remove_key
657
+ keyseq = parse_bindkey_keyseq(remove_key)
658
+ @state.key_bindings.delete(keyseq)
659
+ return true
660
+ end
661
+
662
+ remaining_args = args[i..]
663
+
664
+ # No more args - list all bindings
665
+ if remaining_args.empty?
666
+ return list_bindkey_bindings(keymap)
667
+ end
668
+
669
+ # One arg - show binding for that key
670
+ if remaining_args.length == 1
671
+ keyseq = parse_bindkey_keyseq(remaining_args[0])
672
+ binding = @state.key_bindings[keyseq]
673
+ if binding
674
+ puts "\"#{format_bindkey_keyseq(keyseq)}\" #{binding[:value]}"
675
+ else
676
+ puts "\"#{format_bindkey_keyseq(keyseq)}\" undefined-key"
677
+ end
678
+ return true
679
+ end
680
+
681
+ # Two args - bind key to widget/macro
682
+ keyseq = parse_bindkey_keyseq(remaining_args[0])
683
+ value = remaining_args[1]
684
+
685
+ if macro_binding
686
+ # -s: bind to macro (string output)
687
+ @state.key_bindings[keyseq] = {type: :macro, value: parse_bindkey_keyseq(value), keymap: keymap || 'main'}
688
+ else
689
+ # Bind to widget (function)
690
+ @state.key_bindings[keyseq] = {type: :function, value: value, keymap: keymap || 'main'}
691
+ end
692
+
693
+ true
694
+ end
695
+
696
+ # Parse zsh-style key sequence (e.g., "^A", "^[a", "\e[A")
697
+ def parse_bindkey_keyseq(str)
698
+ return str if str.nil? || str.empty?
699
+
700
+ result = +''
701
+ i = 0
702
+
703
+ # Remove surrounding quotes if present
704
+ if (str.start_with?('"') && str.end_with?('"')) ||
705
+ (str.start_with?("'") && str.end_with?("'"))
706
+ str = str[1...-1]
707
+ end
708
+
709
+ while i < str.length
710
+ char = str[i]
711
+
712
+ if char == '^' && i + 1 < str.length
713
+ # ^X -> Ctrl-X
714
+ next_char = str[i + 1]
715
+ if next_char == '?'
716
+ result << "\x7F" # DEL
717
+ elsif next_char == '['
718
+ result << "\e" # ESC
719
+ else
720
+ # Convert to control character
721
+ result << (next_char.upcase.ord & 0x1F).chr
722
+ end
723
+ i += 2
724
+ elsif char == '\\' && i + 1 < str.length
725
+ next_char = str[i + 1]
726
+ case next_char
727
+ when 'e', 'E'
728
+ result << "\e"
729
+ i += 2
730
+ when 'n'
731
+ result << "\n"
732
+ i += 2
733
+ when 'r'
734
+ result << "\r"
735
+ i += 2
736
+ when 't'
737
+ result << "\t"
738
+ i += 2
739
+ when '\\'
740
+ result << '\\'
741
+ i += 2
742
+ when 'C'
743
+ # \C-x -> Ctrl-x
744
+ if i + 3 < str.length && str[i + 2] == '-'
745
+ ctrl_char = str[i + 3]
746
+ result << (ctrl_char.upcase.ord & 0x1F).chr
747
+ i += 4
748
+ else
749
+ result << char
750
+ i += 1
751
+ end
752
+ when 'M'
753
+ # \M-x -> Meta-x (ESC + x)
754
+ if i + 3 < str.length && str[i + 2] == '-'
755
+ result << "\e" << str[i + 3]
756
+ i += 4
757
+ else
758
+ result << char
759
+ i += 1
760
+ end
761
+ else
762
+ result << next_char
763
+ i += 2
764
+ end
765
+ else
766
+ result << char
767
+ i += 1
768
+ end
769
+ end
770
+
771
+ result
772
+ end
773
+
774
+ # Format key sequence for display
775
+ def format_bindkey_keyseq(keyseq)
776
+ return '' if keyseq.nil? || keyseq.empty?
777
+
778
+ result = +''
779
+ keyseq.each_char do |char|
780
+ ord = char.ord
781
+ if ord < 32
782
+ if ord == 27
783
+ result << '^['
784
+ else
785
+ result << '^' << (ord + 64).chr
786
+ end
787
+ elsif ord == 127
788
+ result << '^?'
789
+ else
790
+ result << char
791
+ end
792
+ end
793
+ result
794
+ end
795
+
796
+ # List all key bindings in bindkey format
797
+ def list_bindkey_bindings(keymap = nil)
798
+ if @state.key_bindings.empty?
799
+ # Show some default bindings
800
+ puts '"^A" beginning-of-line'
801
+ puts '"^E" end-of-line'
802
+ puts '"^K" kill-line'
803
+ puts '"^U" unix-line-discard'
804
+ puts '"^W" backward-kill-word'
805
+ return true
806
+ end
807
+
808
+ @state.key_bindings.each do |keyseq, binding|
809
+ next if keymap && binding[:keymap] != keymap
810
+
811
+ formatted_key = format_bindkey_keyseq(keyseq)
812
+ case binding[:type]
813
+ when :function
814
+ puts "\"#{formatted_key}\" #{binding[:value]}"
815
+ when :macro
816
+ puts "\"#{formatted_key}\" \"#{format_bindkey_keyseq(binding[:value])}\""
817
+ when :command
818
+ puts "\"#{formatted_key}\" \"#{binding[:value]}\""
819
+ end
820
+ end
821
+ true
822
+ end
823
+
824
+ # Select a keymap (emacs or vi)
825
+ def select_keymap(keymap)
826
+ case keymap
827
+ when 'emacs', 'main'
828
+ apply_readline_variable('editing-mode', 'emacs')
829
+ when 'viins', 'vicmd', 'vi'
830
+ apply_readline_variable('editing-mode', 'vi')
831
+ end
832
+ end
833
+ end
834
+ end