summaryrefslogtreecommitdiffhomepage
path: root/mrbgems/raylib/mrblib/bridge.rb
blob: 58e26c7cd71296429f71b960bd1b2604d6b4d355 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
# Jamstack agent bridge (R1): run Ruby in the LIVE game over a localhost TCP
# socket, drained ONCE PER FRAME on the main thread (P6 — never on a socket
# callback). Dev-only, gated by JAMSTACK_BRIDGE=1 (read via Jamstack.getenv; this
# mruby has no ENV). stdout is captured by the C fd-redirect helpers
# Jamstack.__cap_begin / __cap_end because mruby's puts/print bypass $stdout.
#
# Wire protocol (R1; R4 formalizes/unifies to JSON both ways via the relay):
#   request  : one line  "<id> <code>"  with <code> escaped (\\ -> \\\\, newline
#              -> \n, tab -> \t). <id> is a token with no spaces.
#   response : one line of JSON {"id","ok","result","stdout","error","backtrace"}
#
# See .agents/knowledge/agent-bridge.md.
module Jamstack
  module Bridge
    DEFAULT_PORT  = 7621
    MAX_PER_FRAME = 16   # bound eval work per frame so the game keeps drawing

    @active  = false
    @server  = nil
    @clients = []        # [{ sock:, buf: }]
    @queue   = []        # [[id, code, sock], ...]
    @binding = nil       # optional game binding for HTML/agent-bridge REPLs

    class << self
      def active? = @active

      def enabled? = !Jamstack.getenv('JAMSTACK_BRIDGE').nil?

      # Opt the running game into exposing its local-variable scope to the
      # bridge eval (web HTML REPL, desktop bin/eval). Without this, eval runs
      # at top level and cannot read/write game locals. Mirrors Jamstack::Console
      # which is constructed with binding: binding().
      def set_binding(b)
        @binding = b
      end

      def binding
        @binding
      end

      def port
        p = Jamstack.getenv('JAMSTACK_BRIDGE_PORT')
        (p && !p.empty?) ? p.to_i : DEFAULT_PORT
      end

      def start(p = port)
        return if @active
        @server  = TCPServer.new('127.0.0.1', p)
        @clients = []
        @queue   = []
        @active  = true
        emit_log("listening on 127.0.0.1:#{p}")
      rescue => e
        @active = false
        emit_log("failed to start on #{p}: #{e.class}: #{e.message}")
      end

      # Called once per frame from Rl.while_window_open, BEFORE the game block.
      def drain
        return unless @active
        accept_new
        read_clients
        n = 0
        while n < MAX_PER_FRAME && [email protected]?
          id, code, sock = @queue.shift
          send_line(sock, response_json(id, eval_code(code)))
          n += 1
        end
      end

      # Run one snippet, capturing value + stdout + error. Never raises.
      # Uses the game's registered binding (set_binding) when present so HTML/agent
      # REPLs can read/write game locals; otherwise top-level eval.
      def eval_code(code)
        Jamstack.__cap_begin
        ok = true; result = nil; err = nil; bt = nil
        begin
          result = safe_inspect(@binding ? eval_in_binding(code, @binding, "(bridge)") : eval(code))
        rescue Exception => e
          ok  = false
          err = "#{e.class}: #{e.message}"
          bt  = e.backtrace || []
        ensure
          out = Jamstack.__cap_end
        end
        # Route failures into the log stream too (AFTER cap_end, so the log line
        # isn't captured as this eval's stdout).
        Jamstack::Log.error("eval error", error: err, backtrace: bt, tag: "bridge") unless ok
        { ok: ok, result: result, stdout: out.to_s, error: err, backtrace: bt }
      end

      # Eval in a binding with assignment write-back. mruby's eval opens a NEW
      # local scope, so `var = expr` would not mutate the binding's existing
      # locals — detect simple `ident = expr` assignments and route them through
      # binding.local_variable_set so the game loop's closure sees the change.
      # Compound ops (`+=`, `x.y =`, `a = b = 1`) and non-identifier LHS fall
      # through to plain eval. Shared by the HTML/agent REPL and Jamstack::Console.
      # Only locals that existed when the binding was captured can be modified.
      def eval_in_binding(line, binding, file = "(repl)")
        var_name, expr = parse_assignment(line)
        if var_name
          value = eval(expr, binding, file, 1)
          binding.local_variable_set(var_name.to_sym, value)
          value
        else
          eval(line, binding, file, 1)
        end
      end

      def parse_assignment(line)
        eq_idx = line.index("=")
        return nil unless eq_idx

        prev = eq_idx > 0 ? line[eq_idx - 1] : ""
        nxt = line[eq_idx + 1] || ""

        return nil if nxt == "="
        return nil if nxt == ">"
        return nil if "=<>!".include?(prev)
        return nil if "+-*/%".include?(prev)

        var_name = line[0...eq_idx].strip
        expr = line[(eq_idx + 1)..].strip
        return nil unless valid_identifier?(var_name)
        [var_name, expr]
      end

      def valid_identifier?(s)
        return false if s.empty?
        first = s[0]
        return false if first >= "0" && first <= "9"
        s.each_char do |c|
          return false unless ident_char?(c)
        end
        true
      end

      def ident_char?(c)
        (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") ||
          (c >= "0" && c <= "9") || c == "_"
      end

      # -- Tab completion (shared by HTML/agent REPL and Jamstack::Console) --
      # Splits text at the last `::` (constant) or `.` (method) separator;
      # otherwise treats the whole text as a toplevel prefix.
      def parse_completion(text)
        if idx = text.rindex("::")
          [text[0...idx], text[(idx + 2)..], :constant]
        elsif idx = text.rindex(".")
          [text[0...idx], text[(idx + 1)..], :method]
        else
          [nil, text, :toplevel]
        end
      end

      def gather_candidates(receiver_expr, prefix, kind, binding)
        cands =
          case kind
          when :toplevel
            locals = binding.local_variables.map(&:to_s)
            meths  = binding.receiver.public_methods.map(&:to_s)
            consts = Object.constants.map(&:to_s)
            locals + meths + consts
          when :method
            recv = eval(receiver_expr, binding) rescue nil
            return [] if recv.nil?
            recv.public_methods.map(&:to_s)
          when :constant
            recv = eval(receiver_expr, binding) rescue nil
            return [] if recv.nil? || !recv.respond_to?(:constants)
            recv.constants.map(&:to_s)
          end
        cands.uniq.select { |c| c.start_with?(prefix) }.sort
      end

      def common_prefix(strings)
        return "" if strings.empty?
        prefix = strings[0]
        i = 1
        while i < strings.length
          s = strings[i]
          while !s.start_with?(prefix)
            prefix = prefix[0...-1]
            return "" if prefix.empty?
          end
          i += 1
        end
        prefix
      end

      # Completion result as a Hash. `completed` is the value to write into the
      # input on a unique match OR a common-prefix extension (nil = no change).
      # `single` distinguishes the two. `candidates` is the full filtered list
      # (the caller lists them when there's more than one).
      def complete_hash(text)
        return { ok: true, base: "", common: "", completed: nil, candidates: [], single: false } if text.to_s.empty?
        return { ok: false, error: "no binding registered (call Jamstack::Bridge.set_binding)" } unless @binding
        receiver_expr, prefix, kind = parse_completion(text)
        candidates = gather_candidates(receiver_expr, prefix, kind, @binding)
        sep = kind == :constant ? "::" : "."
        base = receiver_expr ? receiver_expr + sep : ""
        if candidates.empty?
          { ok: true, base: base, common: prefix, completed: nil, candidates: [], single: false }
        elsif candidates.length == 1
          { ok: true, base: base, common: candidates[0], completed: base + candidates[0], candidates: candidates, single: true }
        else
          common = common_prefix(candidates)
          { ok: true, base: base, common: common,
            completed: (common.length > prefix.length ? base + common : nil),
            candidates: candidates, single: false }
        end
      end

      # JSON string for the HTML/agent REPL. Call via
      #   Module.jamstack('Jamstack::Bridge.complete_json(<single-quoted text>)')
      # then double-parse: JSON.parse(env).result is this JSON string.
      def complete_json(text)
        Jamstack::JSON.generate(complete_hash(text))
      end

      # Eval and return the JSON response envelope as a String. The web jamstack_eval
      # C export calls this directly (wasm is single-threaded, so no socket/queue).
      def eval_json(code, id = nil)
        response_json(id, eval_code(code))
      end

      private

      def safe_inspect(v)
        v.inspect
      rescue Exception => e
        "#<uninspectable #{v.class}: #{e.class}>"
      end

      def accept_new
        loop do
          begin
            sock = @server.accept_nonblock
          rescue
            break                       # EAGAIN: nothing pending this frame
          end
          @clients << { sock: sock, buf: "" }
        end
      end

      def read_clients
        dead = []
        @clients.each do |c|
          loop do
            begin
              chunk = c[:sock].recv_nonblock(4096)
            rescue
              break                      # EAGAIN: no more data this frame
            end
            if chunk.nil? || chunk.empty? # peer closed
              dead << c
              break
            end
            c[:buf] << chunk
          end
          extract_lines(c)
        end
        dead.each { |c| close_client(c) }
      end

      def extract_lines(c)
        while (i = c[:buf].index("\n"))
          line = c[:buf].slice!(0, i + 1).chomp
          next if line.strip.empty?
          enqueue_line(c[:sock], line)
        end
      end

      def enqueue_line(sock, line)
        sp = line.index(' ')
        if sp
          id   = line[0...sp]
          code = unescape(line[(sp + 1)..-1])
        else
          id   = line
          code = ''
        end
        @queue << [id, code, sock]
      end

      def unescape(s)
        out = ""
        i = 0
        n = s.length
        while i < n
          ch = s[i]
          if ch == "\\" && i + 1 < n
            nx = s[i + 1]
            out << (nx == 'n' ? "\n" : nx == 't' ? "\t" : nx == 'r' ? "\r" : nx)
            i += 2
          else
            out << ch
            i += 1
          end
        end
        out
      end

      def close_client(c)
        @clients.delete(c)
        begin; c[:sock].close; rescue; end
      end

      def send_line(sock, str)
        sock.write(str)
        sock.write("\n")
      rescue
        # client vanished mid-write; reaped on next read
      end

      def response_json(id, env)
        Jamstack::JSON.generate(
          "id"        => id,
          "ok"        => env[:ok],
          "result"    => env[:result],
          "stdout"    => env[:stdout],
          "error"     => env[:error],
          "backtrace" => env[:backtrace]
        )
      end

      def emit_log(msg)
        Jamstack::Log.info(msg, tag: "bridge")
      end
    end
  end
end