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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +12 -3
  3. data/README.md +446 -8
  4. data/bin/srsh +71 -0
  5. data/docs/assets/slut.txt +4 -0
  6. data/docs/assets/srsh-mark.svg +12 -0
  7. data/docs/css/style.css +696 -0
  8. data/docs/index.html +703 -0
  9. data/docs/js/app.js +203 -0
  10. data/examples/bridge.rsh +8 -0
  11. data/examples/calculator.rsh +253 -0
  12. data/examples/defer.rsh +14 -0
  13. data/examples/hot.rsh +14 -0
  14. data/examples/meta.rsh +20 -0
  15. data/examples/modules/text.rsh +6 -0
  16. data/examples/modules.rsh +6 -0
  17. data/examples/paste.rsh +15 -0
  18. data/examples/plugin.rb +8 -0
  19. data/examples/power.rsh +65 -0
  20. data/examples/tour.rsh +38 -0
  21. data/ext/srsh_native/extconf.rb +3 -0
  22. data/ext/srsh_native/srsh_native.c +48 -0
  23. data/language-docs/LANGUAGE.md +670 -0
  24. data/language-docs/MIGRATION.md +44 -0
  25. data/language-docs/SECURITY.md +44 -0
  26. data/lib/srsh/app.rb +261 -0
  27. data/lib/srsh/builtins.rb +492 -0
  28. data/lib/srsh/editor.rb +530 -0
  29. data/lib/srsh/errors.rb +23 -0
  30. data/lib/srsh/history.rb +74 -0
  31. data/lib/srsh/language/evaluator.rb +1175 -0
  32. data/lib/srsh/language/lexer.rb +316 -0
  33. data/lib/srsh/language/parser.rb +997 -0
  34. data/lib/srsh/language/token.rb +5 -0
  35. data/lib/srsh/language/values.rb +392 -0
  36. data/lib/srsh/paths.rb +29 -0
  37. data/lib/srsh/plugins.rb +59 -0
  38. data/lib/srsh/process_identity.rb +38 -0
  39. data/lib/srsh/security.rb +38 -0
  40. data/lib/srsh/shell/executor.rb +1182 -0
  41. data/lib/srsh/shell/job.rb +101 -0
  42. data/lib/srsh/shell/lexer.rb +114 -0
  43. data/lib/srsh/shell/terminal.rb +26 -0
  44. data/lib/srsh/state.rb +136 -0
  45. data/lib/srsh/theme.rb +108 -0
  46. data/lib/srsh/version.rb +3 -0
  47. data/lib/srsh.rb +11 -5
  48. metadata +61 -14
  49. data/exe/srsh +0 -6
  50. data/lib/srsh/runner.rb +0 -2416
@@ -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
@@ -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
@@ -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