dopairb 0.1.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.
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # Cheap, side-effect free facts about results and exceptions. Never calls
5
+ # the user's #inspect / #to_s: those may be slow, raise, or print.
6
+ module Probe
7
+ KERNEL_CLASS = Kernel.instance_method(:class)
8
+ MODULE_NAME = Module.instance_method(:name)
9
+ STR_LENGTH = String.instance_method(:length)
10
+ STR_SLICE = String.instance_method(:[])
11
+ ARY_SIZE = Array.instance_method(:size)
12
+ HASH_SIZE = Hash.instance_method(:size)
13
+ SYM_NAME = Symbol.instance_method(:name)
14
+ INT_BITS = Integer.instance_method(:bit_length)
15
+ INT_TO_S = Integer.instance_method(:to_s)
16
+ EXC_MESSAGE = Exception.instance_method(:message)
17
+
18
+ HUGE_BITS = 4000
19
+
20
+ module_function
21
+
22
+ def value(v, code = "")
23
+ info = classify(v)
24
+ if (m = code.match(/\A\s*def\s+(?:self\.)?([^\s(=;]+)/)) && info[:type] == :symbol
25
+ return { type: :definition, what: :method, name: m[1] }
26
+ end
27
+ if (m = code.match(/\A\s*(class|module)\s+(?!<<)([A-Z][\w:]*)/))
28
+ return { type: :definition, what: m[1].to_sym, name: m[2] }
29
+ end
30
+ info
31
+ rescue StandardError
32
+ { type: :object, class_name: "?" }
33
+ end
34
+
35
+ def classify(v)
36
+ if NilClass === v then { type: :nil }
37
+ elsif TrueClass === v then { type: :true }
38
+ elsif FalseClass === v then { type: :false }
39
+ elsif Integer === v
40
+ bits = INT_BITS.bind_call(v)
41
+ if bits > HUGE_BITS
42
+ { type: :huge, digits: (bits * 0.30103).floor + 1 }
43
+ else
44
+ { type: :integer, value: v }
45
+ end
46
+ elsif Float === v then { type: :float, value: v }
47
+ elsif String === v
48
+ len = STR_LENGTH.bind_call(v)
49
+ { type: :string, length: len, head: sanitize(STR_SLICE.bind_call(v, 0, 160) || "") }
50
+ elsif Array === v then { type: :array, size: ARY_SIZE.bind_call(v) }
51
+ elsif Hash === v then { type: :hash, size: HASH_SIZE.bind_call(v) }
52
+ elsif Symbol === v then { type: :symbol, name: SYM_NAME.bind_call(v) }
53
+ else { type: :object, class_name: class_name(v) }
54
+ end
55
+ end
56
+
57
+ def class_name(v)
58
+ MODULE_NAME.bind_call(KERNEL_CLASS.bind_call(v)) || "(anonymous)"
59
+ rescue TypeError
60
+ "BasicObject"
61
+ end
62
+
63
+ def sanitize(s)
64
+ s = s.dup.force_encoding(Encoding::UTF_8)
65
+ s = s.scrub("?")
66
+ s.gsub(/[\p{Cc}\p{Cf}]/) { |c| c == "\n" ? " " : "." }
67
+ rescue StandardError
68
+ "?"
69
+ end
70
+
71
+ def message(exc)
72
+ sanitize(EXC_MESSAGE.bind_call(exc).to_s.lines.first.to_s.chomp)
73
+ rescue Exception # rubocop:disable Lint/RescueException
74
+ ""
75
+ end
76
+
77
+ def exception(exc, code = "")
78
+ klass = MODULE_NAME.bind_call(KERNEL_CLASS.bind_call(exc)) || "Exception"
79
+ msg = message(exc)
80
+ info = { class_name: klass, message: msg }
81
+ case exc
82
+ when SyntaxError
83
+ info[:type] = :syntax
84
+ info.merge!(syntax_location(code))
85
+ when NoMethodError
86
+ info[:type] = :nomethod
87
+ info[:name] = safe_name(exc)
88
+ info[:receiver] = receiver_name(exc, msg)
89
+ when NameError
90
+ info[:type] = :name
91
+ info[:name] = safe_name(exc)
92
+ when TypeError
93
+ info[:type] = :type
94
+ info[:sides] = type_sides(msg)
95
+ when ArgumentError
96
+ info[:type] = :argument
97
+ info[:sides] = argument_sides(msg)
98
+ else
99
+ info[:type] = interrupt?(exc) ? :interrupt : :other
100
+ end
101
+ info
102
+ rescue StandardError
103
+ { type: :other, class_name: "Exception", message: "" }
104
+ end
105
+
106
+ def interrupt?(exc)
107
+ Interrupt === exc || (defined?(IRB::Abort) && IRB::Abort === exc)
108
+ end
109
+
110
+ def safe_name(exc)
111
+ n = NameError.instance_method(:name).bind_call(exc)
112
+ Symbol === n ? SYM_NAME.bind_call(n) : nil
113
+ rescue StandardError
114
+ nil
115
+ end
116
+
117
+ def receiver_name(exc, msg)
118
+ if (m = msg.match(/for (?:an instance of |)([A-Z][\w:]*|nil|true|false)/))
119
+ return m[1]
120
+ end
121
+ r = NameError.instance_method(:receiver).bind_call(exc)
122
+ Module === r ? MODULE_NAME.bind_call(r).to_s : class_name(r)
123
+ rescue StandardError
124
+ "?"
125
+ end
126
+
127
+ def syntax_location(code)
128
+ return {} unless defined?(Prism)
129
+ err = Prism.parse(code).errors.first
130
+ return {} unless err
131
+ loc = err.location
132
+ { line: loc.start_line, column: loc.start_column, detail: sanitize(err.message) }
133
+ rescue StandardError
134
+ {}
135
+ end
136
+
137
+ def type_sides(msg)
138
+ if (m = msg.match(/(?:no implicit conversion of|conversion of) (\S+) into (\S+)/))
139
+ [m[1], m[2]]
140
+ elsif (m = msg.match(/(\S+) can't be coerced into (\S+)/))
141
+ [m[1], m[2]]
142
+ elsif (m = msg.match(/wrong argument type (\S+) \(expected (\S+)\)/))
143
+ [m[1], m[2]]
144
+ else
145
+ %w[TYPE TYPE]
146
+ end
147
+ end
148
+
149
+ def argument_sides(msg)
150
+ if (m = msg.match(/given (\d+), expected ([\d.+\-]+)/))
151
+ ["given #{m[1]}", "expected #{m[2]}"]
152
+ elsif (m = msg.match(/(missing|unknown) keywords?: (.+)/))
153
+ [m[1], m[2][0, 20]]
154
+ else
155
+ %w[ARG ARG]
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # Every dependency on Reline internals lives in this file.
5
+ #
6
+ # Tested with Reline 0.6.x. Public hooks used: add_dialog_proc,
7
+ # output_modifier_proc. Internal ones (prepended): LineEditor#input_key,
8
+ # #handle_signal (called every 10ms while Reline waits for a key -- our
9
+ # animation tick), #render, #render_finished, Core#readmultiline.
10
+ module RelineAdapter
11
+ SUPPORTED = Gem::Requirement.new(">= 0.5.0", "< 0.8")
12
+ DIALOGS = %i[dopa_strip dopa_trail dopa_hud].freeze
13
+ TICK = 1 / 30.0
14
+
15
+ class << self
16
+ attr_reader :fx, :editor
17
+
18
+ def supported?
19
+ defined?(Reline::VERSION) && SUPPORTED.satisfied_by?(Gem::Version.new(Reline::VERSION))
20
+ end
21
+
22
+ def install(fx)
23
+ @fx = fx
24
+ return false unless supported?
25
+ return true if @installed
26
+ Reline::LineEditor.prepend(LineEditorHook)
27
+ Reline::Core.prepend(CoreHook)
28
+ DIALOGS.each do |name|
29
+ kind = name.to_s.delete_prefix("dopa_").to_sym
30
+ Reline.add_dialog_proc(name, ->() { Dopairb::RelineAdapter.dialog(kind, self) }, nil)
31
+ end
32
+ @installed = true
33
+ end
34
+
35
+ def active?
36
+ @installed && Dopairb.active? && !@broken
37
+ end
38
+
39
+ def keys? = active? && Dopairb.config.keys
40
+
41
+ # Something went wrong inside our hooks: stop decorating, keep the REPL.
42
+ def broken!(e)
43
+ Dopairb.debug(e)
44
+ @broken = true
45
+ end
46
+
47
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+
49
+ # ---- called from hooks ------------------------------------------------
50
+
51
+ def before_read(core)
52
+ wrap_output_modifier(core)
53
+ @editor = core.line_editor
54
+ return unless keys?
55
+ @fx.reset_line
56
+ # Keep a lane of blank rows under the prompt for the effect strip.
57
+ n = InputFx::STRIP_ROWS
58
+ Term.write("\eD" * n + "\e[#{n}A")
59
+ end
60
+
61
+ def wrap_output_modifier(core)
62
+ orig = core.output_modifier_proc
63
+ return if orig&.instance_variable_get(:@dopairb)
64
+ memo = [nil, nil, nil]
65
+ wrapper = proc do |input, complete:|
66
+ # highlights re-render the same text many times; colorize it once
67
+ if memo[0] == input && memo[1] == complete
68
+ colored = memo[2]
69
+ else
70
+ colored = orig ? orig.call(input, complete: complete) : nil
71
+ memo.replace([input.dup, complete, colored])
72
+ end
73
+ if complete || !RelineAdapter.keys?
74
+ colored
75
+ else
76
+ begin
77
+ RelineAdapter.fx.apply(colored || Reline::Unicode.escape_for_print(input), input)
78
+ rescue StandardError => e
79
+ RelineAdapter.broken!(e)
80
+ colored
81
+ end
82
+ end
83
+ end
84
+ wrapper.instance_variable_set(:@dopairb, true)
85
+ core.output_modifier_proc = wrapper
86
+ end
87
+
88
+ def after_key(le, key, before, prev_x, pasting)
89
+ @editor = le
90
+ return if le.finished?
91
+ after = [le.whole_lines, le.instance_variable_get(:@line_index), le.instance_variable_get(:@byte_pointer)]
92
+ cur_x, = le.wrapped_cursor_position
93
+ @fx.on_key(key.method_symbol, before, after, prev_x: prev_x, cur_x: cur_x, pasting: pasting)
94
+ end
95
+
96
+ def layout(le)
97
+ key = [le.whole_lines, le.instance_variable_get(:@byte_pointer), le.instance_variable_get(:@line_index), le.screen_width, le.screen_scroll_top]
98
+ return @layout[1] if @layout && @layout[0] == key
99
+ lay = compute_layout(le)
100
+ @layout = [key.map { |k| k.frozen? ? k : k.dup }, lay]
101
+ lay
102
+ end
103
+
104
+ def compute_layout(le)
105
+ x, y = le.wrapped_cursor_position
106
+ top = le.screen_scroll_top
107
+ rows = le.wrapped_prompt_and_input_lines.flatten(1)
108
+ prompt, line = rows[y] || ["", ""]
109
+ InputFx::Layout.new(cursor_x: x, cursor_row: y - top,
110
+ row_end_x: Term.str_width(prompt.to_s) + Term.str_width(line.to_s),
111
+ last_row: rows.size - 1 - top, screen_width: le.screen_width,
112
+ room_below: le.rest_height(y))
113
+ end
114
+
115
+ def dialog(kind, scope)
116
+ return nil unless keys? && @editor
117
+ lay = layout(@editor)
118
+ info = case kind
119
+ when :hud
120
+ r = @fx.hud_render(lay)
121
+ r && Reline::DialogRenderInfo.new(pos: Reline::CursorPos.new(r[0], -1), contents: r[1], height: 1)
122
+ when :trail
123
+ r = @fx.trail_render(lay)
124
+ r && Reline::DialogRenderInfo.new(pos: Reline::CursorPos.new(r[0], -1), contents: r[1], height: 1)
125
+ when :strip
126
+ return nil if scope.completion_journey_data
127
+ dy = lay.last_row - lay.cursor_row
128
+ return nil if lay.room_below - dy < InputFx::STRIP_ROWS
129
+ lines = @fx.strip_render(lay)
130
+ lines && Reline::DialogRenderInfo.new(pos: Reline::CursorPos.new(0, dy), contents: lines,
131
+ width: lay.screen_width - 1, height: InputFx::STRIP_ROWS)
132
+ end
133
+ info
134
+ rescue StandardError => e
135
+ broken!(e)
136
+ nil
137
+ end
138
+
139
+ def tick(le)
140
+ return unless keys?
141
+ t = now
142
+ return if @last_tick && t - @last_tick < TICK
143
+ @last_tick = t
144
+ @editor = le
145
+ return unless @fx.animating?(t)
146
+ le.__send__(:dopairb_refresh)
147
+ rescue StandardError => e
148
+ broken!(e)
149
+ end
150
+
151
+ def hl_dirty? = keys? && @fx.hl_dirty?
152
+
153
+ # Snapshot what render_finished is about to print.
154
+ def capture(le, rows, lines)
155
+ @last_input = InputShot.new(rows: rows, lines: lines, code: le.whole_buffer, screen_width: le.screen_width)
156
+ rescue StandardError => e
157
+ Dopairb.debug(e)
158
+ @last_input = nil
159
+ end
160
+
161
+ # The shot of the input that produced `code`, if it is still on screen.
162
+ def take_input(code)
163
+ shot = @last_input
164
+ @last_input = nil
165
+ return nil unless shot
166
+ return nil unless shot.code.chomp == code.chomp
167
+ return nil if shot.rows.size > Term.rows - 3
168
+ shot
169
+ end
170
+ end
171
+
172
+ module LineEditorHook
173
+ def input_key(key)
174
+ return super unless RelineAdapter.keys?
175
+ if @in_pasting
176
+ # keep pastes cheap: no layout work, just count
177
+ RelineAdapter.fx.on_key(nil, nil, nil, prev_x: nil, cur_x: nil, pasting: true) unless key.char.nil?
178
+ return super
179
+ end
180
+ before = nil
181
+ prev_x = nil
182
+ pasting = false
183
+ begin
184
+ before = [whole_lines.map(&:dup), @line_index, @byte_pointer]
185
+ prev_x, = wrapped_cursor_position
186
+ rescue StandardError => e
187
+ RelineAdapter.broken!(e)
188
+ end
189
+ result = super
190
+ if before
191
+ begin
192
+ RelineAdapter.after_key(self, key, before, prev_x, pasting)
193
+ rescue StandardError => e
194
+ RelineAdapter.broken!(e)
195
+ end
196
+ end
197
+ result
198
+ end
199
+
200
+ def handle_signal
201
+ super
202
+ RelineAdapter.tick(self) unless @in_pasting || finished?
203
+ end
204
+
205
+ def render
206
+ @cache.delete(:modified_lines) if RelineAdapter.hl_dirty?
207
+ super
208
+ end
209
+
210
+ def render_finished
211
+ if RelineAdapter.active?
212
+ begin
213
+ lines = []
214
+ rows = @buffer_of_lines.size.times.flat_map do |i|
215
+ prompt = Reline::Unicode.strip_non_printing_start_end(prompt_list[i])
216
+ line = prompt + modified_lines[i]
217
+ lines << [Term.str_width(prompt), whole_lines[i]]
218
+ wrapped = split_line_by_width(line, screen_width)
219
+ wrapped.last.empty? ? split_line_by_width("#{line} ", screen_width) : wrapped
220
+ end
221
+ RelineAdapter.capture(self, rows, lines)
222
+ rescue StandardError => e
223
+ Dopairb.debug(e)
224
+ end
225
+ end
226
+ super
227
+ end
228
+
229
+ private
230
+
231
+ def dopairb_refresh
232
+ x, y = wrapped_cursor_position
233
+ @dialogs.each do |d|
234
+ update_each_dialog(d, x, y - screen_scroll_top) if RelineAdapter::DIALOGS.include?(d.name)
235
+ end
236
+ render
237
+ end
238
+ end
239
+
240
+ module CoreHook
241
+ def readmultiline(prompt = "", add_hist = false, &block)
242
+ RelineAdapter.before_read(self) if RelineAdapter.active?
243
+ super
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # Snapshot of the submitted input as it sits on screen right above the cursor.
5
+ class InputShot
6
+ attr_reader :rows, :plain, :code, :prompt_widths, :lines, :screen_width
7
+
8
+ # rows: physical rows (with SGR) exactly as Reline printed them
9
+ # lines: [[prompt_width, code_line], ...] per logical line
10
+ def initialize(rows:, lines:, code:, screen_width:)
11
+ @rows = rows
12
+ @plain = rows.map { |r| r.gsub(/\e\[[\d;?]*[A-Za-z]/, "").gsub(/[\x01\x02]/, "") }
13
+ @lines = lines
14
+ @code = code
15
+ @screen_width = screen_width
16
+ end
17
+
18
+ def size = @rows.size
19
+
20
+ # [row, x] on screen of (logical line index, byte column)
21
+ def locate(line_idx, byte_col)
22
+ row = 0
23
+ @lines.each_with_index do |(pw, text), i|
24
+ if i == line_idx
25
+ prefix = text.byteslice(0, byte_col).to_s.scrub("?")
26
+ x = pw + Term.str_width(prefix)
27
+ return [row + x / @screen_width, x % @screen_width]
28
+ end
29
+ row += (pw + Term.str_width(text)) / @screen_width + 1
30
+ end
31
+ nil
32
+ end
33
+
34
+ # [[row, x, width], ...] for each word-bounded occurrence of name
35
+ def find(name)
36
+ return [] if name.nil? || name.empty?
37
+ hits = []
38
+ re = /(?<![\w@$])#{Regexp.escape(name)}(?![\w?!])/
39
+ @lines.each_with_index do |(_, text), i|
40
+ text.to_enum(:scan, re).each do
41
+ b = Regexp.last_match.byteoffset(0)[0]
42
+ pos = locate(i, b)
43
+ hits << [pos[0], pos[1], Term.str_width(name)] if pos
44
+ end
45
+ end
46
+ hits
47
+ end
48
+ end
49
+
50
+ # Everything a scene needs to know about where it is drawing.
51
+ Ctx = Struct.new(:w, :rows, :depth, :config, :rng, :input, :pre, :compact, keyword_init: true)
52
+
53
+ class Scene
54
+ attr_reader :ctx, :event
55
+
56
+ def initialize(ctx, event = nil)
57
+ @ctx = ctx
58
+ @event = event
59
+ @rng = ctx.rng
60
+ @w = ctx.w
61
+ setup
62
+ end
63
+
64
+ def setup; end
65
+
66
+ def height = 1
67
+ def length = 0.4
68
+ def top? = false
69
+ def big? = false
70
+ def mega? = false
71
+ def bell? = mega?
72
+ def trail = nil
73
+ def shake(_t) = 0
74
+ def flash_at = nil
75
+
76
+ # Wind-up before the main animation (input row glow); 0 when not wanted.
77
+ def pre = top? ? ctx.pre.to_f : 0.0
78
+ def total_length = pre + length
79
+
80
+ # Top rows (the input) as canvas, or nil to leave them untouched.
81
+ def draw_top(c, t)
82
+ return nil unless t < pre
83
+ glow_input(c, t / pre)
84
+ true
85
+ end
86
+
87
+ def draw(_c, _t); end
88
+
89
+ def w = @w
90
+ def depth = ctx.depth
91
+
92
+ def paint(text, col, bold: false)
93
+ return text if depth == :none && !bold
94
+ "#{Color.sgr(col, nil, bold, depth)}#{text}\e[0m"
95
+ end
96
+
97
+ def sub_bits(extra = [])
98
+ bits = extra.dup
99
+ return bits unless event
100
+ bits << "+#{Fx.number_with_commas(event.gain)}" if event.gain.to_i > 0
101
+ bits << "COMBO #{event.combo}" if event.combo.to_i > 1
102
+ bits << "NEW RECORD" if event.flag?(:new_record) && !bits.include?("NEW RECORD")
103
+ bits << "FIRST HIT" if event.flag?(:first_hit)
104
+ bits << "RECOVERED after #{event.streak} error#{'s' if event.streak > 1}" if event.flag?(:comeback)
105
+ bits
106
+ end
107
+
108
+ def sub_line(extra = [])
109
+ sub_bits(extra).join(" ")
110
+ end
111
+
112
+ protected
113
+
114
+ def plain_input(c, color = [150, 150, 160])
115
+ ctx.input.plain.each_with_index { |r, y| c.put(0, y, r, color) }
116
+ end
117
+
118
+ # A hot band sweeping across the submitted code: "EXECUTE".
119
+ def glow_input(c, p)
120
+ band = p * (@w + 16) - 8
121
+ ctx.input.plain.each_with_index do |r, y|
122
+ x = 0
123
+ r.each_char do |ch|
124
+ d = (x - band - y * 2).abs
125
+ col = d < 6 ? Color.mix([255, 255, 255], [255, 170, 40], d / 6.0) : [200, 200, 215]
126
+ bg = d < 3 ? Color.mix([255, 120, 0], [60, 20, 0], d / 3.0) : nil
127
+ c.put(x, y, ch, col, bg: bg, bold: d < 3)
128
+ x += ch.ord < 0x80 ? 1 : Term.char_width(ch)
129
+ end
130
+ end
131
+ end
132
+
133
+ def combo_palette(combo = event&.combo.to_i)
134
+ case combo
135
+ when 0..2 then Color::ICE
136
+ when 3..5 then Color::TOXIC
137
+ when 6..9 then Color::GOLD
138
+ when 10..19 then Color::FIRE
139
+ else Color::NEON
140
+ end
141
+ end
142
+
143
+ def combo_color(t = 0, combo = event&.combo.to_i)
144
+ return Color.rainbow(t * 1.5) if combo >= 20
145
+ Color.ramp(combo_palette(combo), 0.25)
146
+ end
147
+
148
+ def ease(t) = Fx.ease_out(t)
149
+
150
+ def best_scale(text)
151
+ ctx.compact ? nil : Fx.best_scale(text, @w)
152
+ end
153
+ end
154
+ end