srsh 0.8.0 → 1.0.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 +4 -4
- data/LICENSE +12 -3
- data/README.md +446 -8
- data/bin/srsh +71 -0
- data/docs/assets/slut.txt +4 -0
- data/docs/assets/srsh-mark.svg +12 -0
- data/docs/css/style.css +696 -0
- data/docs/index.html +703 -0
- data/docs/js/app.js +203 -0
- data/examples/bridge.rsh +8 -0
- data/examples/calculator.rsh +253 -0
- data/examples/defer.rsh +14 -0
- data/examples/hot.rsh +14 -0
- data/examples/meta.rsh +20 -0
- data/examples/modules/text.rsh +6 -0
- data/examples/modules.rsh +6 -0
- data/examples/paste.rsh +15 -0
- data/examples/plugin.rb +8 -0
- data/examples/power.rsh +65 -0
- data/examples/tour.rsh +38 -0
- data/ext/srsh_native/extconf.rb +3 -0
- data/ext/srsh_native/srsh_native.c +48 -0
- data/language-docs/LANGUAGE.md +670 -0
- data/language-docs/MIGRATION.md +44 -0
- data/language-docs/SECURITY.md +44 -0
- data/lib/srsh/app.rb +261 -0
- data/lib/srsh/builtins.rb +492 -0
- data/lib/srsh/editor.rb +530 -0
- data/lib/srsh/errors.rb +23 -0
- data/lib/srsh/history.rb +74 -0
- data/lib/srsh/language/evaluator.rb +1175 -0
- data/lib/srsh/language/lexer.rb +316 -0
- data/lib/srsh/language/parser.rb +997 -0
- data/lib/srsh/language/token.rb +5 -0
- data/lib/srsh/language/values.rb +392 -0
- data/lib/srsh/paths.rb +29 -0
- data/lib/srsh/plugins.rb +59 -0
- data/lib/srsh/process_identity.rb +38 -0
- data/lib/srsh/security.rb +38 -0
- data/lib/srsh/shell/executor.rb +1182 -0
- data/lib/srsh/shell/job.rb +101 -0
- data/lib/srsh/shell/lexer.rb +114 -0
- data/lib/srsh/shell/terminal.rb +26 -0
- data/lib/srsh/state.rb +136 -0
- data/lib/srsh/theme.rb +108 -0
- data/lib/srsh/version.rb +3 -0
- data/lib/srsh.rb +11 -5
- metadata +61 -14
- data/exe/srsh +0 -6
- data/lib/srsh/runner.rb +0 -2416
data/lib/srsh/editor.rb
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
require 'io/console'
|
|
2
|
+
|
|
3
|
+
module Srsh
|
|
4
|
+
class Editor
|
|
5
|
+
ANSI_RE = /\e\[[0-9;?]*[ -\/]*[@-~]/
|
|
6
|
+
EXEC_CACHE_TTL = 2.5
|
|
7
|
+
BRACKETED_PASTE_START = '[200~'.freeze
|
|
8
|
+
BRACKETED_PASTE_END = "\e[201~".freeze
|
|
9
|
+
MAX_PASTE_BYTES = 4 * 1024 * 1024
|
|
10
|
+
LANGUAGE_WORDS = %w[if else end each while fn task proto trait slot space use defer bridge try catch finally match code emit return break continue].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(app, input: STDIN, output: STDOUT)
|
|
13
|
+
@app = app
|
|
14
|
+
@history = app.history
|
|
15
|
+
@input = input
|
|
16
|
+
@output = output
|
|
17
|
+
@render_rows = 0
|
|
18
|
+
@exec_path = nil
|
|
19
|
+
@exec_entries = []
|
|
20
|
+
@exec_built_at = 0.0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def read(prompt)
|
|
24
|
+
return fallback(prompt) unless interactive_console?
|
|
25
|
+
|
|
26
|
+
chars = []
|
|
27
|
+
cursor = 0
|
|
28
|
+
hist_index = @history.length
|
|
29
|
+
saved_line = []
|
|
30
|
+
last_tab_prefix = nil
|
|
31
|
+
@render_rows = 0
|
|
32
|
+
|
|
33
|
+
paste_mode = false
|
|
34
|
+
console.raw do |io|
|
|
35
|
+
enable_bracketed_paste
|
|
36
|
+
paste_mode = true
|
|
37
|
+
render(prompt, chars, cursor)
|
|
38
|
+
loop do
|
|
39
|
+
ch = io.getch
|
|
40
|
+
|
|
41
|
+
case ch
|
|
42
|
+
when "\r", "\n"
|
|
43
|
+
cursor = chars.length
|
|
44
|
+
render(prompt, chars, cursor, show_ghost: false)
|
|
45
|
+
@output.print "\r\n"
|
|
46
|
+
@output.flush
|
|
47
|
+
return chars.join
|
|
48
|
+
when "\u0003" # Ctrl-C
|
|
49
|
+
render(prompt, chars, cursor, show_ghost: false)
|
|
50
|
+
@output.print "^C\r\n"
|
|
51
|
+
@output.flush
|
|
52
|
+
@app.state.last_status = 130
|
|
53
|
+
return :interrupt
|
|
54
|
+
when "\u0004" # Ctrl-D
|
|
55
|
+
if chars.empty?
|
|
56
|
+
clear_render
|
|
57
|
+
@output.print "\r\n"
|
|
58
|
+
@output.flush
|
|
59
|
+
return :eof
|
|
60
|
+
end
|
|
61
|
+
when "\u0001" # Ctrl-A
|
|
62
|
+
cursor = 0
|
|
63
|
+
last_tab_prefix = nil
|
|
64
|
+
when "\u0005" # Ctrl-E
|
|
65
|
+
cursor = chars.length
|
|
66
|
+
last_tab_prefix = nil
|
|
67
|
+
when "\u000b" # Ctrl-K
|
|
68
|
+
chars.slice!(cursor..)
|
|
69
|
+
last_tab_prefix = nil
|
|
70
|
+
when "\u0015" # Ctrl-U
|
|
71
|
+
chars.slice!(0...cursor)
|
|
72
|
+
cursor = 0
|
|
73
|
+
last_tab_prefix = nil
|
|
74
|
+
when "\u0017" # Ctrl-W
|
|
75
|
+
while cursor.positive? && whitespace?(chars[cursor - 1])
|
|
76
|
+
chars.delete_at(cursor -= 1)
|
|
77
|
+
end
|
|
78
|
+
while cursor.positive? && !whitespace?(chars[cursor - 1])
|
|
79
|
+
chars.delete_at(cursor -= 1)
|
|
80
|
+
end
|
|
81
|
+
last_tab_prefix = nil
|
|
82
|
+
when "\u000c" # Ctrl-L
|
|
83
|
+
@output.print "\e[2J\e[H"
|
|
84
|
+
@render_rows = 0
|
|
85
|
+
when "\u007f", "\b"
|
|
86
|
+
chars.delete_at(cursor -= 1) if cursor.positive?
|
|
87
|
+
hist_index = @history.length
|
|
88
|
+
last_tab_prefix = nil
|
|
89
|
+
when "\t"
|
|
90
|
+
chars, cursor, last_tab_prefix, printed = handle_tab_completion(prompt, chars, cursor, last_tab_prefix)
|
|
91
|
+
@render_rows = 1 if printed
|
|
92
|
+
when "\e"
|
|
93
|
+
seq = read_escape(io)
|
|
94
|
+
case seq
|
|
95
|
+
when BRACKETED_PASTE_START
|
|
96
|
+
pasted = read_bracketed_paste(io)
|
|
97
|
+
pasted = normalize_paste(pasted)
|
|
98
|
+
|
|
99
|
+
if pasted.include?("\n")
|
|
100
|
+
before = chars[0...cursor].join
|
|
101
|
+
after = chars[cursor..]&.join.to_s
|
|
102
|
+
program = before + pasted + after
|
|
103
|
+
action = confirm_multiline_paste(io, prompt, program)
|
|
104
|
+
if action == :accept
|
|
105
|
+
@output.print "\r\n"
|
|
106
|
+
@output.flush
|
|
107
|
+
return program
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
chars = []
|
|
111
|
+
cursor = 0
|
|
112
|
+
hist_index = @history.length
|
|
113
|
+
saved_line = []
|
|
114
|
+
last_tab_prefix = nil
|
|
115
|
+
@render_rows = 0
|
|
116
|
+
render(prompt, chars, cursor)
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
pasted.each_char do |char|
|
|
121
|
+
chars.insert(cursor, char)
|
|
122
|
+
cursor += 1
|
|
123
|
+
end
|
|
124
|
+
hist_index = @history.length
|
|
125
|
+
last_tab_prefix = nil
|
|
126
|
+
when '[A'
|
|
127
|
+
if hist_index == @history.length
|
|
128
|
+
saved_line = chars.dup
|
|
129
|
+
end
|
|
130
|
+
if hist_index.positive?
|
|
131
|
+
hist_index -= 1
|
|
132
|
+
chars = @history[hist_index].to_s.each_char.to_a
|
|
133
|
+
cursor = chars.length
|
|
134
|
+
end
|
|
135
|
+
when '[B'
|
|
136
|
+
if hist_index < @history.length - 1
|
|
137
|
+
hist_index += 1
|
|
138
|
+
chars = @history[hist_index].to_s.each_char.to_a
|
|
139
|
+
elsif hist_index == @history.length - 1
|
|
140
|
+
hist_index = @history.length
|
|
141
|
+
chars = saved_line.dup
|
|
142
|
+
end
|
|
143
|
+
cursor = chars.length
|
|
144
|
+
when '[C'
|
|
145
|
+
if cursor < chars.length
|
|
146
|
+
cursor += 1
|
|
147
|
+
elsif (suggestion = ghost_for(chars.join))
|
|
148
|
+
chars = suggestion.each_char.to_a
|
|
149
|
+
cursor = chars.length
|
|
150
|
+
end
|
|
151
|
+
when '[D'
|
|
152
|
+
cursor -= 1 if cursor.positive?
|
|
153
|
+
when '[H', 'OH', '[1~', '[7~'
|
|
154
|
+
cursor = 0
|
|
155
|
+
when '[F', 'OF', '[4~', '[8~'
|
|
156
|
+
cursor = chars.length
|
|
157
|
+
when '[3~'
|
|
158
|
+
chars.delete_at(cursor) if cursor < chars.length
|
|
159
|
+
end
|
|
160
|
+
last_tab_prefix = nil
|
|
161
|
+
else
|
|
162
|
+
if printable?(ch)
|
|
163
|
+
ch.each_char do |char|
|
|
164
|
+
chars.insert(cursor, char)
|
|
165
|
+
cursor += 1
|
|
166
|
+
end
|
|
167
|
+
hist_index = @history.length
|
|
168
|
+
last_tab_prefix = nil
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
render(prompt, chars, cursor)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
rescue Errno::EIO, IOError
|
|
176
|
+
fallback(prompt)
|
|
177
|
+
ensure
|
|
178
|
+
disable_bracketed_paste if paste_mode
|
|
179
|
+
@render_rows = 0
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def console
|
|
185
|
+
IO.console
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def interactive_console?
|
|
189
|
+
@input.equal?(STDIN) && @output.equal?(STDOUT) && STDIN.tty? && STDOUT.tty? && console
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def fallback(prompt)
|
|
193
|
+
@output.print prompt
|
|
194
|
+
@output.flush
|
|
195
|
+
line = @input.gets
|
|
196
|
+
line ? line.chomp : :eof
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def printable?(ch)
|
|
200
|
+
return false if ch.nil? || ch.empty?
|
|
201
|
+
ch.each_codepoint.all? { |cp| cp >= 32 && cp != 127 }
|
|
202
|
+
rescue ArgumentError
|
|
203
|
+
false
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def whitespace?(ch)
|
|
207
|
+
ch && ch.match?(/\s/)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def enable_bracketed_paste
|
|
211
|
+
@output.print "\e[?2004h"
|
|
212
|
+
@output.flush
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def disable_bracketed_paste
|
|
216
|
+
@output.print "\e[?2004l"
|
|
217
|
+
@output.flush
|
|
218
|
+
rescue IOError, SystemCallError
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def normalize_paste(text)
|
|
223
|
+
text.to_s.gsub("\r\n", "\n").tr("\r", "\n")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Bracketed paste gives us a hard boundary around clipboard input. Keep
|
|
227
|
+
# reading until the terminal's end marker instead of letting embedded
|
|
228
|
+
# newlines masquerade as Enter key presses.
|
|
229
|
+
def read_bracketed_paste(io)
|
|
230
|
+
data = +''
|
|
231
|
+
loop do
|
|
232
|
+
ch = io.getch
|
|
233
|
+
raise IOError, 'paste ended unexpectedly' unless ch
|
|
234
|
+
data << ch
|
|
235
|
+
if data.end_with?(BRACKETED_PASTE_END)
|
|
236
|
+
data.delete_suffix!(BRACKETED_PASTE_END)
|
|
237
|
+
return data
|
|
238
|
+
end
|
|
239
|
+
raise Error, 'pasted input is too large' if data.bytesize > MAX_PASTE_BYTES
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def confirm_multiline_paste(io, prompt, program)
|
|
244
|
+
lines = program.lines.count
|
|
245
|
+
first = program.lines.find { |line| !line.strip.empty? }.to_s.strip
|
|
246
|
+
first = first.each_char.take(44).join + (first.each_char.count > 44 ? '…' : '')
|
|
247
|
+
|
|
248
|
+
clear_render
|
|
249
|
+
message = "[pasted #{lines} lines"
|
|
250
|
+
message << ": #{first}" unless first.empty?
|
|
251
|
+
message << ': Enter to run, Ctrl-C to cancel]'
|
|
252
|
+
@output.print "\r", prompt, @app.theme.paint(message, :dim, io: @output)
|
|
253
|
+
@output.flush
|
|
254
|
+
@render_rows = [(visible_length(prompt) + visible_length(message)).fdiv(terminal_width).ceil, 1].max
|
|
255
|
+
|
|
256
|
+
loop do
|
|
257
|
+
ch = io.getch
|
|
258
|
+
case ch
|
|
259
|
+
when "\r", "\n"
|
|
260
|
+
clear_render
|
|
261
|
+
@output.print "\r", prompt, @app.theme.paint("[running pasted #{lines}-line program]", :dim, io: @output)
|
|
262
|
+
@output.flush
|
|
263
|
+
@render_rows = 1
|
|
264
|
+
return :accept
|
|
265
|
+
when "\u0003", "\e"
|
|
266
|
+
clear_render
|
|
267
|
+
@output.print "\r", prompt, @app.theme.paint('[paste cancelled]', :dim, io: @output), "\r\n"
|
|
268
|
+
@output.flush
|
|
269
|
+
@app.state.last_status = 130 if ch == "\u0003"
|
|
270
|
+
@render_rows = 0
|
|
271
|
+
return :cancel
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# ESC can arrive before the rest of a CSI sequence. Waiting a tiny bounded
|
|
277
|
+
# amount avoids both the old permanent block on a lone Escape key and the
|
|
278
|
+
# rewrite's race where arrow-key bytes were often missed entirely.
|
|
279
|
+
def read_escape(io)
|
|
280
|
+
out = +''
|
|
281
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.04
|
|
282
|
+
while out.bytesize < 12
|
|
283
|
+
remain = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
284
|
+
break if remain <= 0
|
|
285
|
+
break unless IO.select([io], nil, nil, remain)
|
|
286
|
+
c = io.getch
|
|
287
|
+
break unless c
|
|
288
|
+
out << c
|
|
289
|
+
break if escape_sequence_complete?(out)
|
|
290
|
+
end
|
|
291
|
+
out
|
|
292
|
+
rescue IOError, SystemCallError
|
|
293
|
+
out || ''
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def escape_sequence_complete?(seq)
|
|
297
|
+
return true if seq.start_with?('O') && seq.length >= 2 && seq[-1].match?(/[A-Za-z]/)
|
|
298
|
+
return false unless seq.start_with?('[')
|
|
299
|
+
!!(seq =~ /\A\[[0-9;?]*[A-Za-z~]\z/)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def ghost_for(prefix)
|
|
303
|
+
return nil if prefix.nil? || prefix.empty?
|
|
304
|
+
@history.reverse_each do |line|
|
|
305
|
+
next if line.nil? || line.empty?
|
|
306
|
+
next if line.start_with?('[completions:')
|
|
307
|
+
next unless line.start_with?(prefix)
|
|
308
|
+
next if line == prefix
|
|
309
|
+
return line
|
|
310
|
+
end
|
|
311
|
+
nil
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def render(prompt, chars, cursor, show_ghost: true)
|
|
315
|
+
chars ||= []
|
|
316
|
+
cursor = [[cursor, 0].max, chars.length].min
|
|
317
|
+
text = chars.join
|
|
318
|
+
ghost_tail = ''
|
|
319
|
+
|
|
320
|
+
if show_ghost && cursor == chars.length
|
|
321
|
+
suggestion = ghost_for(text)
|
|
322
|
+
ghost_tail = suggestion ? suggestion.each_char.to_a[chars.length..]&.join.to_s : ''
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
prompt_vis = visible_length(prompt)
|
|
326
|
+
text_vis = visible_length(text)
|
|
327
|
+
ghost_vis = visible_length(ghost_tail)
|
|
328
|
+
total_vis = prompt_vis + text_vis + ghost_vis
|
|
329
|
+
width = terminal_width
|
|
330
|
+
rows = [(total_vis.to_f / width).ceil, 1].max
|
|
331
|
+
|
|
332
|
+
clear_render
|
|
333
|
+
@output.print "\r", prompt, text
|
|
334
|
+
@output.print @app.theme.paint(ghost_tail, :dim, io: @output) unless ghost_tail.empty?
|
|
335
|
+
|
|
336
|
+
# Critical old-SRSH behavior: the ghost is painted *after* the logical
|
|
337
|
+
# cursor, so move back across both it and any real text to the cursor's
|
|
338
|
+
# right. Without the ghost length the terminal cursor lies to the user.
|
|
339
|
+
real_tail_vis = visible_length(chars[cursor..]&.join.to_s)
|
|
340
|
+
move_left = ghost_vis + real_tail_vis
|
|
341
|
+
@output.print "\e[#{move_left}D" if move_left.positive?
|
|
342
|
+
@output.flush
|
|
343
|
+
@render_rows = rows
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def clear_render
|
|
347
|
+
return unless @render_rows.positive?
|
|
348
|
+
@output.print "\r"
|
|
349
|
+
(@render_rows - 1).times { @output.print "\e[1A\r" }
|
|
350
|
+
@render_rows.times do |i|
|
|
351
|
+
@output.print "\e[0K"
|
|
352
|
+
@output.print "\n" if i < @render_rows - 1
|
|
353
|
+
end
|
|
354
|
+
(@render_rows - 1).times { @output.print "\e[1A\r" }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def terminal_width
|
|
358
|
+
width = begin
|
|
359
|
+
if console
|
|
360
|
+
console.winsize[1]
|
|
361
|
+
else
|
|
362
|
+
Integer(ENV['COLUMNS'], exception: false)
|
|
363
|
+
end
|
|
364
|
+
rescue SystemCallError, IOError
|
|
365
|
+
nil
|
|
366
|
+
end
|
|
367
|
+
width = 80 unless width && width.positive?
|
|
368
|
+
[width, 20].max
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def visible_length(text)
|
|
372
|
+
text.to_s.gsub(ANSI_RE, '').each_char.count
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def handle_tab_completion(prompt, chars, cursor, last_tab_prefix)
|
|
376
|
+
buffer = chars.join
|
|
377
|
+
cursor = [[cursor, 0].max, chars.length].min
|
|
378
|
+
char_prefix = chars[0...cursor].join
|
|
379
|
+
match = char_prefix.rindex(/[ \t]/)
|
|
380
|
+
byte_start = match ? match + 1 : 0
|
|
381
|
+
prefix = char_prefix[byte_start..].to_s
|
|
382
|
+
word_start_chars = char_prefix[0...byte_start].each_char.count
|
|
383
|
+
|
|
384
|
+
before_word = chars[0...word_start_chars].join
|
|
385
|
+
at_first_word = before_word.strip.empty?
|
|
386
|
+
first_word = buffer.strip.split(/\s+/, 2)[0].to_s
|
|
387
|
+
completions = tab_completions_for(prefix, first_word, at_first_word)
|
|
388
|
+
return [chars, cursor, nil, false] if completions.empty?
|
|
389
|
+
|
|
390
|
+
if completions.length == 1
|
|
391
|
+
replacement = completions.first.each_char.to_a
|
|
392
|
+
chars[word_start_chars...cursor] = replacement
|
|
393
|
+
return [chars, word_start_chars + replacement.length, nil, true]
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
if prefix != last_tab_prefix
|
|
397
|
+
common = longest_common_prefix(completions)
|
|
398
|
+
if common.length > prefix.length
|
|
399
|
+
replacement = common.each_char.to_a
|
|
400
|
+
chars[word_start_chars...cursor] = replacement
|
|
401
|
+
cursor = word_start_chars + replacement.length
|
|
402
|
+
else
|
|
403
|
+
@output.print "\a"
|
|
404
|
+
@output.flush
|
|
405
|
+
end
|
|
406
|
+
return [chars, cursor, prefix, false]
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
render(prompt, chars, cursor, show_ghost: false)
|
|
410
|
+
print_tab_list(completions)
|
|
411
|
+
[chars, cursor, prefix, true]
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def tab_completions_for(prefix, first_word, at_first_word)
|
|
415
|
+
prefix ||= ''
|
|
416
|
+
file_completions = path_completions(prefix, first_word)
|
|
417
|
+
exec_completions = []
|
|
418
|
+
|
|
419
|
+
if first_word != 'cat' && first_word != 'cd' && at_first_word && !prefix.include?('/')
|
|
420
|
+
names = @app.builtins.names + @app.state.functions.keys + @app.state.prototypes.keys +
|
|
421
|
+
@app.state.traits.keys + @app.state.aliases.keys + LANGUAGE_WORDS + executable_names
|
|
422
|
+
exec_completions = names.grep(/^#{Regexp.escape(prefix)}/)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
(file_completions + exec_completions).uniq.sort
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def path_completions(prefix, first_word)
|
|
429
|
+
dir = '.'
|
|
430
|
+
base = prefix
|
|
431
|
+
|
|
432
|
+
if prefix.include?('/')
|
|
433
|
+
if prefix.end_with?('/')
|
|
434
|
+
dir = prefix == '/' ? '/' : prefix.chomp('/')
|
|
435
|
+
base = ''
|
|
436
|
+
else
|
|
437
|
+
dir = File.dirname(prefix)
|
|
438
|
+
base = File.basename(prefix)
|
|
439
|
+
end
|
|
440
|
+
dir = '.' if dir.nil? || dir.empty?
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
lookup_dir = expand_completion_dir(dir)
|
|
444
|
+
return [] unless Dir.exist?(lookup_dir)
|
|
445
|
+
|
|
446
|
+
Dir.children(lookup_dir).filter_map do |entry|
|
|
447
|
+
next unless entry.start_with?(base)
|
|
448
|
+
full = File.join(lookup_dir, entry)
|
|
449
|
+
shown = dir == '.' ? entry : File.join(File.dirname(prefix), entry)
|
|
450
|
+
|
|
451
|
+
case first_word
|
|
452
|
+
when 'cd'
|
|
453
|
+
next unless File.directory?(full)
|
|
454
|
+
shown.end_with?('/') ? shown : "#{shown}/"
|
|
455
|
+
when 'cat'
|
|
456
|
+
File.file?(full) ? shown : nil
|
|
457
|
+
else
|
|
458
|
+
File.directory?(full) && !shown.end_with?('/') ? "#{shown}/" : shown
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
rescue SystemCallError
|
|
462
|
+
[]
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def expand_completion_dir(dir)
|
|
466
|
+
return @app.paths.home if dir == '~'
|
|
467
|
+
return File.join(@app.paths.home, dir[2..]) if dir.start_with?('~/')
|
|
468
|
+
File.expand_path(dir)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def executable_names
|
|
472
|
+
path = ENV['PATH'].to_s
|
|
473
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
474
|
+
if @exec_path != path || now - @exec_built_at > EXEC_CACHE_TTL
|
|
475
|
+
@exec_path = path
|
|
476
|
+
@exec_built_at = now
|
|
477
|
+
@exec_entries = []
|
|
478
|
+
path.split(File::PATH_SEPARATOR).each do |dir|
|
|
479
|
+
dir = '.' if dir.nil? || dir.empty?
|
|
480
|
+
begin
|
|
481
|
+
Dir.children(dir).each do |entry|
|
|
482
|
+
full = File.join(dir, entry)
|
|
483
|
+
@exec_entries << entry if File.file?(full) && File.executable?(full)
|
|
484
|
+
end
|
|
485
|
+
rescue SystemCallError
|
|
486
|
+
next
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
@exec_entries.uniq!
|
|
490
|
+
end
|
|
491
|
+
@exec_entries
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def longest_common_prefix(strings)
|
|
495
|
+
return '' if strings.empty?
|
|
496
|
+
shortest = strings.min_by { |s| s.each_char.count }.to_s.each_char.to_a
|
|
497
|
+
shortest.length.times do |i|
|
|
498
|
+
c = shortest[i]
|
|
499
|
+
strings.each do |s|
|
|
500
|
+
chars = s.each_char.to_a
|
|
501
|
+
return shortest[0...i].join if chars[i] != c
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
shortest.join
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def print_tab_list(completions)
|
|
508
|
+
return if completions.empty?
|
|
509
|
+
width = terminal_width
|
|
510
|
+
max_len = completions.map { |s| visible_length(s) }.max || 0
|
|
511
|
+
col_width = [max_len + 2, 4].max
|
|
512
|
+
cols = [width / col_width, 1].max
|
|
513
|
+
rows = (completions.length.to_f / cols).ceil
|
|
514
|
+
|
|
515
|
+
@output.print "\r\n"
|
|
516
|
+
rows.times do |row|
|
|
517
|
+
line = +''
|
|
518
|
+
cols.times do |col|
|
|
519
|
+
index = col * rows + row
|
|
520
|
+
break if index >= completions.length
|
|
521
|
+
item = completions[index]
|
|
522
|
+
line << item << (' ' * [col_width - visible_length(item), 0].max)
|
|
523
|
+
end
|
|
524
|
+
@output.print "\r", line.rstrip, "\n"
|
|
525
|
+
end
|
|
526
|
+
@output.print "\r\n"
|
|
527
|
+
@output.flush
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|
data/lib/srsh/errors.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Srsh
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
class ParseError < Error
|
|
4
|
+
attr_reader :line, :column
|
|
5
|
+
def initialize(message, line: nil, column: nil)
|
|
6
|
+
@line = line
|
|
7
|
+
@column = column
|
|
8
|
+
where = line ? " at #{line}:#{column || 1}" : ''
|
|
9
|
+
super("#{message}#{where}")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
# ParseError means the input is wrong. IncompleteInput is the useful
|
|
13
|
+
# subset: the parser reached the end while it was still waiting for input.
|
|
14
|
+
# The interactive shell uses this to decide whether to show the `...` prompt.
|
|
15
|
+
class IncompleteInput < ParseError; end
|
|
16
|
+
class RuntimeError < Error; end
|
|
17
|
+
class BreakSignal < Exception; end
|
|
18
|
+
class NextSignal < Exception; end
|
|
19
|
+
class ReturnSignal < Exception
|
|
20
|
+
attr_reader :value
|
|
21
|
+
def initialize(value = nil) = (@value = value)
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/srsh/history.rb
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require_relative 'security'
|
|
2
|
+
|
|
3
|
+
module Srsh
|
|
4
|
+
class History
|
|
5
|
+
include Enumerable
|
|
6
|
+
DEFAULT_MAX = 5000
|
|
7
|
+
|
|
8
|
+
def initialize(path, import_paths: [])
|
|
9
|
+
@path = path
|
|
10
|
+
@import_paths = Array(import_paths).reject { |p| p == path }.uniq
|
|
11
|
+
@max = Integer(ENV.fetch('SRSH_HISTORY_MAX', DEFAULT_MAX), exception: false) || DEFAULT_MAX
|
|
12
|
+
@max = DEFAULT_MAX unless @max.positive?
|
|
13
|
+
@items = []
|
|
14
|
+
load_path(path)
|
|
15
|
+
@import_paths.each { |other| load_path(other, promote_duplicates: true) }
|
|
16
|
+
trim!
|
|
17
|
+
@dirty = @import_paths.any? { |p| File.file?(p) }
|
|
18
|
+
rescue SystemCallError, ArgumentError
|
|
19
|
+
@items ||= []
|
|
20
|
+
@dirty = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def each(&block) = @items.each(&block)
|
|
24
|
+
def length = @items.length
|
|
25
|
+
def [](index) = @items[index]
|
|
26
|
+
def reverse_each(&block) = @items.reverse_each(&block)
|
|
27
|
+
|
|
28
|
+
def add(line)
|
|
29
|
+
line = line.to_s
|
|
30
|
+
return if line.empty? || @items.last == line
|
|
31
|
+
@items << line
|
|
32
|
+
trim!
|
|
33
|
+
@dirty = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clear
|
|
37
|
+
@items.clear
|
|
38
|
+
@dirty = true
|
|
39
|
+
flush
|
|
40
|
+
@import_paths.each do |path|
|
|
41
|
+
Security.atomic_write(path, '') if File.file?(path)
|
|
42
|
+
rescue SystemCallError
|
|
43
|
+
next
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def flush
|
|
48
|
+
return unless @dirty
|
|
49
|
+
Security.atomic_write(@path, @items.join("\n") + (@items.empty? ? '' : "\n"))
|
|
50
|
+
@dirty = false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def load_path(path, promote_duplicates: false)
|
|
56
|
+
return unless File.file?(path)
|
|
57
|
+
File.foreach(path, chomp: true) do |line|
|
|
58
|
+
next if line.empty?
|
|
59
|
+
@items.delete(line) if promote_duplicates
|
|
60
|
+
@items << line
|
|
61
|
+
# Keep startup memory bounded even if a corrupted/ancient history file
|
|
62
|
+
# contains millions of lines. Trim in batches to avoid O(n^2) shifting.
|
|
63
|
+
@items = @items.last(@max) if @items.length > (@max * 2)
|
|
64
|
+
end
|
|
65
|
+
rescue SystemCallError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def trim!
|
|
70
|
+
extra = @items.length - @max
|
|
71
|
+
@items.shift(extra) if extra.positive?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|