ruby-test-ide 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.
Files changed (118) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +218 -0
  4. data/exe/ruby-test-ide +42 -0
  5. data/lib/ruby_test_ide/observer.rb +97 -0
  6. data/lib/ruby_test_ide/public/index.html +611 -0
  7. data/lib/ruby_test_ide/public/vendor/monaco/LICENSE +21 -0
  8. data/lib/ruby_test_ide/public/vendor/monaco/ThirdPartyNotices.txt +448 -0
  9. data/lib/ruby_test_ide/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  10. data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.de.js +8 -0
  11. data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.es.js +8 -0
  12. data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.fr.js +8 -0
  13. data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.it.js +8 -0
  14. data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.ja.js +8 -0
  15. data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.js +8 -0
  16. data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.ko.js +8 -0
  17. data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.ru.js +8 -0
  18. data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.zh-cn.js +8 -0
  19. data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.zh-tw.js +8 -0
  20. data/lib/ruby_test_ide/public/vendor/monaco/vs/base/worker/workerMain.js +27 -0
  21. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  22. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  23. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  24. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  25. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  26. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  27. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  28. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  29. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  30. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  31. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  32. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  33. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  34. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  35. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  36. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  37. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  38. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  39. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  40. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  41. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  42. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  43. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  44. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  45. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  46. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  47. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  48. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  49. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  50. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  51. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  52. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  53. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  54. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  55. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  56. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  57. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  58. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  59. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  60. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  61. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  62. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  63. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  64. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  65. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  66. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  67. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  68. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  69. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  70. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  71. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  72. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  73. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  74. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  75. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  76. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  77. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  78. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  79. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  80. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  81. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  82. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  83. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  84. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  85. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  86. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  87. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  88. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  89. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  90. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  91. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  92. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  93. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  94. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  95. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  96. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  97. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  98. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  99. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  100. data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  101. data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.css +6 -0
  102. data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.js +745 -0
  103. data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.de.js +31 -0
  104. data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.es.js +31 -0
  105. data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.fr.js +29 -0
  106. data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.it.js +29 -0
  107. data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.ja.js +31 -0
  108. data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.js +29 -0
  109. data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.ko.js +29 -0
  110. data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.ru.js +31 -0
  111. data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.zh-cn.js +31 -0
  112. data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.zh-tw.js +29 -0
  113. data/lib/ruby_test_ide/public/vendor/monaco/vs/loader.js +11 -0
  114. data/lib/ruby_test_ide/runner.rb +532 -0
  115. data/lib/ruby_test_ide/server.rb +724 -0
  116. data/lib/ruby_test_ide/version.rb +5 -0
  117. data/lib/ruby_test_ide.rb +12 -0
  118. metadata +166 -0
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # RubyTestIDE runtime-introspection worker.
5
+ #
6
+ # Reads ONE JSON request on stdin, evaluates the user's code in a clean
7
+ # top-level binding (exactly the way `irb` / a debugger would), then
8
+ # interrogates the resulting *live* objects for completions, hover info or
9
+ # per-line variable state. Writes ONE JSON response on stdout.
10
+ #
11
+ # It is spawned per-request by server.rb and hard-killed if it exceeds the
12
+ # time budget, so user code that loops forever cannot take the IDE down.
13
+ #
14
+ # NOTE: this executes the code being edited (that is the whole point of the
15
+ # runtime-reflection approach). Treat it like running `ruby yourfile.rb`.
16
+
17
+ require 'json'
18
+ require 'stringio'
19
+ require 'pathname'
20
+ require 'timeout'
21
+
22
+ module RubyTestIDE
23
+ SANDBOX_PATH = '(ruby-ide)'
24
+ MAX_TRACE_EVENTS = 50_000
25
+ MAX_ITEMS = 300
26
+ RECEIVER_TIMEOUT = 2 # seconds for evaluating the completion receiver
27
+ OBS_REL = File.join('.ruby-ide', 'observations.json')
28
+
29
+ KEYWORDS = %w[
30
+ BEGIN END alias and begin break case class def defined? do else elsif end
31
+ ensure false for if in module next nil not or redo rescue retry return
32
+ self super then true undef unless until when while yield
33
+ require require_relative attr_accessor attr_reader attr_writer
34
+ ].freeze
35
+
36
+ class << self
37
+ def main
38
+ # Results go out on fd 3 when the server provides it, so user code
39
+ # printing to stdout can never corrupt the JSON protocol. Fallback to
40
+ # stdout (with capture) when run by hand: `echo '{...}' | ruby runner.rb`
41
+ channel = begin
42
+ IO.new(3, 'w')
43
+ rescue StandardError
44
+ nil
45
+ end
46
+ request = JSON.parse($stdin.read)
47
+ captured = nil
48
+ unless channel
49
+ real_stdout = $stdout
50
+ $stdout = StringIO.new
51
+ end
52
+ response =
53
+ begin
54
+ handle(request)
55
+ rescue Exception => e # rubocop:disable Lint/RescueException
56
+ { 'error' => "#{e.class}: #{e.message}"[0, 1000] }
57
+ end
58
+ response['bundler_error'] = @bundler_error if @bundler_error
59
+ unless channel
60
+ captured = $stdout.string
61
+ $stdout = real_stdout
62
+ end
63
+ response['stdout'] = truncate(captured, 20_000) if captured && !captured.empty?
64
+ out = channel || $stdout
65
+ out.puts JSON.generate(response)
66
+ out.flush
67
+ # exit! skips at_exit hooks — minitest/autorun must not re-run the
68
+ # suite after the response has been written.
69
+ Process.exit!(0)
70
+ end
71
+
72
+ def handle(req)
73
+ enter_workspace(req)
74
+ case req['op']
75
+ when 'complete' then complete(req)
76
+ when 'hover' then hover(req)
77
+ when 'run' then run(req)
78
+ else { 'error' => "unknown op #{req['op'].inspect}" }
79
+ end
80
+ end
81
+
82
+ # Evaluate "in place": chdir to the file's directory and use its real
83
+ # path as the eval filename, so require_relative / __FILE__ / __dir__
84
+ # behave as they would when running the file with `ruby`. The buffer
85
+ # content still comes from the request (unsaved edits included).
86
+ def enter_workspace(req)
87
+ @sandbox_path = req['path'].to_s.empty? ? SANDBOX_PATH : req['path']
88
+ @workspace_root = req['root'].to_s
89
+ dir = req['dir'].to_s
90
+ if !dir.empty? && File.directory?(dir)
91
+ Dir.chdir(dir)
92
+ $LOAD_PATH.unshift(dir) unless $LOAD_PATH.include?(dir)
93
+ end
94
+ @bundler_error = activate_bundler(dir.empty? ? Dir.pwd : dir)
95
+ shim_require_relative(dir.empty? ? Dir.pwd : dir)
96
+ end
97
+
98
+ # If the workspace has a Gemfile (in the file's directory or above it),
99
+ # activate it so `require 'some-gem'` resolves exactly as it would under
100
+ # `bundle exec ruby file.rb`. Returns an error description (surfaced to
101
+ # the editor) when the bundle isn't installed, nil otherwise.
102
+ def activate_bundler(dir)
103
+ # Walk up from the file's directory, but never above the workspace
104
+ # root — a stray Gemfile in the user's home must not leak in.
105
+ root = File.expand_path(@workspace_root.to_s.empty? ? dir : @workspace_root)
106
+ gemfile = nil
107
+ Pathname.new(File.expand_path(dir)).ascend do |p|
108
+ candidate = p.join('Gemfile')
109
+ if candidate.file?
110
+ gemfile = candidate.to_s
111
+ break
112
+ end
113
+ break if p.to_s == root || !p.to_s.start_with?(root)
114
+ end
115
+ return nil unless gemfile
116
+
117
+ ENV['BUNDLE_GEMFILE'] = gemfile
118
+ # Pin the already-loaded bundler: without this, a lockfile BUNDLED WITH
119
+ # an older installed bundler makes bundler/setup RE-EXEC this process,
120
+ # and the restarted runner finds stdin already consumed (empty request).
121
+ require 'bundler/version'
122
+ ENV['BUNDLER_VERSION'] ||= Bundler::VERSION
123
+ require 'bundler/setup'
124
+ nil
125
+ rescue Exception => e # rubocop:disable Lint/RescueException
126
+ { 'class' => e.class.to_s,
127
+ 'message' => truncate("#{e.message} — try `bundle install` in #{File.dirname(gemfile)}", 400) }
128
+ end
129
+
130
+ # require_relative raises "cannot infer basepath" inside eval'd code.
131
+ # Resolve against the calling file's real directory when there is one
132
+ # (files loaded via require keep normal semantics), otherwise against
133
+ # the buffer's directory.
134
+ def shim_require_relative(base)
135
+ Object.__send__(:define_method, :require_relative) do |rel|
136
+ from = caller_locations(1, 1)&.first&.absolute_path
137
+ require(File.expand_path(rel.to_s, from ? File.dirname(from) : base))
138
+ end
139
+ Object.__send__(:private, :require_relative)
140
+ end
141
+
142
+ def sandbox_path
143
+ @sandbox_path || SANDBOX_PATH
144
+ end
145
+
146
+ # ---------------------------------------------------------------- context
147
+
148
+ # A binding that behaves like the top of a plain ruby script:
149
+ # self is `main`, and — unlike TOPLEVEL_BINDING — a method-body binding
150
+ # cannot leak the runner's own local variables into completions.
151
+ def fresh_binding
152
+ TOPLEVEL_BINDING.receiver.__send__(:__ruby_ide_sandbox__)
153
+ end
154
+
155
+ # Evaluate everything before the cursor. If it doesn't parse (the user is
156
+ # mid-edit inside a def/do block), fall back to the largest prefix of
157
+ # lines that does parse — partial context beats no context.
158
+ def eval_context(code)
159
+ bind = fresh_binding
160
+ error = nil
161
+ code = largest_valid_prefix(code)
162
+ unless code.strip.empty?
163
+ begin
164
+ bind.eval(code, sandbox_path)
165
+ rescue Exception => e # rubocop:disable Lint/RescueException
166
+ error = describe_error(e) # keep partial state assigned before raise
167
+ end
168
+ end
169
+ [bind, error]
170
+ end
171
+
172
+ def largest_valid_prefix(code)
173
+ lines = code.to_s.lines
174
+ until lines.empty?
175
+ candidate = lines.join
176
+ return candidate if valid_syntax?(candidate)
177
+
178
+ lines.pop
179
+ end
180
+ ''
181
+ end
182
+
183
+ def valid_syntax?(code)
184
+ stderr, $stderr = $stderr, StringIO.new
185
+ RubyVM::AbstractSyntaxTree.parse(code)
186
+ true
187
+ rescue SyntaxError
188
+ false
189
+ ensure
190
+ $stderr = stderr
191
+ end
192
+
193
+ def describe_error(e)
194
+ line = e.backtrace_locations&.find { |l| l.path == sandbox_path }&.lineno
195
+ line ||= e.message[/#{Regexp.escape(sandbox_path)}:(\d+)/, 1]&.to_i
196
+ { 'class' => e.class.to_s, 'message' => truncate(e.message, 500), 'line' => line }
197
+ end
198
+
199
+ # ------------------------------------------------------------- completion
200
+
201
+ def complete(req)
202
+ bind, error = eval_context(req['code_before'])
203
+ receiver = req['receiver'].to_s
204
+ prefix = req['prefix'].to_s
205
+
206
+ if receiver.strip.empty?
207
+ { 'items' => bare_items(bind, prefix), 'context_error' => error }.compact
208
+ else
209
+ obj =
210
+ begin
211
+ Timeout.timeout(RECEIVER_TIMEOUT) { bind.eval(receiver, sandbox_path) }
212
+ rescue Exception => e # rubocop:disable Lint/RescueException
213
+ oracle = oracle_complete(bind, receiver, prefix)
214
+ return oracle.merge('context_error' => error).compact if oracle
215
+
216
+ return { 'items' => [], 'context_error' => error,
217
+ 'receiver_error' => truncate("#{e.class}: #{e.message}", 200) }.compact
218
+ end
219
+ { 'items' => receiver_items(obj, prefix),
220
+ 'receiver_class' => class_of(obj).to_s,
221
+ 'context_error' => error }.compact
222
+ end
223
+ end
224
+
225
+ def receiver_items(obj, prefix)
226
+ klass = class_of(obj)
227
+ ancestors = klass.ancestors
228
+ names = obj.methods.map(&:to_s).uniq
229
+ names.select! { |n| n.start_with?(prefix) } unless prefix.empty?
230
+ names.sort.first(MAX_ITEMS).map do |n|
231
+ item = { 'name' => n, 'kind' => 'method' }
232
+ if (m = safe_method(obj, n))
233
+ item['signature'] = signature(n, m)
234
+ item['arity'] = m.arity
235
+ item['owner'] = m.owner.to_s
236
+ item['sort'] = ancestors.index(m.owner) || 98
237
+ end
238
+ item
239
+ end
240
+ end
241
+
242
+ def bare_items(bind, prefix)
243
+ items = []
244
+ main = bind.receiver
245
+
246
+ bind.local_variables.each do |v|
247
+ val = bind.local_variable_get(v)
248
+ items << { 'name' => v.to_s, 'kind' => 'variable', 'class' => class_of(val).to_s,
249
+ 'value' => safe_inspect(val), 'sort' => 0 }
250
+ end
251
+ bind.eval('instance_variables').each do |v|
252
+ items << { 'name' => v.to_s, 'kind' => 'variable', 'sort' => 1 }
253
+ end
254
+ if prefix.start_with?('$')
255
+ global_variables.each { |v| items << { 'name' => v.to_s, 'kind' => 'variable', 'sort' => 3 } }
256
+ end
257
+ (main.methods + main.private_methods).map(&:to_s).uniq.each do |n|
258
+ next if n.start_with?('_')
259
+
260
+ item = { 'name' => n, 'kind' => 'method', 'sort' => 4 }
261
+ if (m = safe_method(main, n))
262
+ item['signature'] = signature(n, m)
263
+ item['owner'] = m.owner.to_s
264
+ end
265
+ items << item
266
+ end
267
+ Object.constants.map(&:to_s).each do |c|
268
+ items << { 'name' => c, 'kind' => 'constant', 'sort' => 2 }
269
+ end
270
+ KEYWORDS.each { |k| items << { 'name' => k, 'kind' => 'keyword', 'sort' => 5 } }
271
+
272
+ items.select! { |i| i['name'].start_with?(prefix) } unless prefix.empty?
273
+ items.uniq { |i| i['name'] }.sort_by { |i| [i['sort'], i['name']] }.first(MAX_ITEMS)
274
+ end
275
+
276
+ # ------------------------------------------------------------------ hover
277
+
278
+ def hover(req)
279
+ bind, error = eval_context(req['code_before'])
280
+ receiver = req['receiver'].to_s
281
+ name = req['name'].to_s
282
+ res = hover_info(bind, receiver, name)
283
+ observed = observed_for(name)
284
+ if res
285
+ res['observed'] = observed if observed && res['kind'] == 'method'
286
+ elsif observed
287
+ # Not resolvable from live objects (unreached path) — build the hover
288
+ # entirely from what the tests saw.
289
+ o = observed.first
290
+ args = (o['args'] || {}).map { |n, classes| "#{n}: #{classes.keys.join(' | ')}" }.join(', ')
291
+ res = { 'kind' => 'method', 'name' => name,
292
+ 'signature' => "#{name}(#{args})",
293
+ 'owner' => o['owner'],
294
+ 'source' => "#{o['file']}:#{o['line']}",
295
+ 'observed' => observed }
296
+ end
297
+ res ||= {}
298
+ res['context_error'] = error if error
299
+ res.empty? ? {} : res
300
+ end
301
+
302
+ def hover_info(bind, receiver, name)
303
+ if receiver.strip.empty?
304
+ if bind.local_variables.map(&:to_s).include?(name)
305
+ val = bind.local_variable_get(name)
306
+ return { 'kind' => 'variable', 'name' => name, 'class' => class_of(val).to_s,
307
+ 'value' => safe_inspect(val) }
308
+ end
309
+ if name.match?(/\A[A-Z]/) && bind.eval("defined?(#{name})") == 'constant'
310
+ val = bind.eval(name, sandbox_path)
311
+ return { 'kind' => val.is_a?(Module) ? 'class' : 'constant', 'name' => name,
312
+ 'class' => class_of(val).to_s, 'value' => safe_inspect(val),
313
+ 'ancestors' => (val.is_a?(Module) ? val.ancestors.first(8).map(&:to_s) : nil) }.compact
314
+ end
315
+ end
316
+
317
+ target =
318
+ begin
319
+ if receiver.strip.empty?
320
+ bind.receiver
321
+ else
322
+ Timeout.timeout(RECEIVER_TIMEOUT) { bind.eval(receiver, sandbox_path) }
323
+ end
324
+ rescue Exception # rubocop:disable Lint/RescueException
325
+ # Receiver unreachable live (network edge, slow call) — fall back
326
+ # to the return class the tests observed for its trailing method.
327
+ klass, via = observed_receiver_class(bind, receiver)
328
+ m = begin
329
+ klass&.instance_method(name)
330
+ rescue StandardError
331
+ nil
332
+ end
333
+ return nil unless m
334
+
335
+ return { 'kind' => 'method', 'name' => name,
336
+ 'signature' => signature(name, m),
337
+ 'arity' => m.arity,
338
+ 'owner' => m.owner.to_s,
339
+ 'receiver_class' => klass.to_s,
340
+ 'via' => via,
341
+ 'source' => m.source_location&.join(':') }.compact
342
+ end
343
+ m = safe_method(target, name)
344
+ return nil unless m
345
+
346
+ { 'kind' => 'method', 'name' => name,
347
+ 'signature' => signature(name, m),
348
+ 'arity' => m.arity,
349
+ 'owner' => m.owner.to_s,
350
+ 'receiver_class' => class_of(target).to_s,
351
+ 'source' => m.source_location&.join(':'),
352
+ 'doc' => nil }.compact
353
+ end
354
+
355
+ # Observations persisted by the server after the last learn run.
356
+ def observations
357
+ return @observations if defined?(@observations)
358
+
359
+ file = @workspace_root.to_s.empty? ? nil : File.join(@workspace_root, OBS_REL)
360
+ @observations = file && File.file?(file) ? JSON.parse(File.read(file)) : nil
361
+ rescue StandardError
362
+ @observations = nil
363
+ end
364
+
365
+ def observed_for(name)
366
+ return nil unless observations
367
+
368
+ list = observations.values.select { |o| o['method'] == name.to_s }
369
+ list.empty? ? nil : list.first(5)
370
+ end
371
+
372
+ # "w.summary('London')" -> "summary"; "fetch(1)" -> "fetch"
373
+ def trailing_call_name(expr)
374
+ expr[/(?:\.|\A)\s*([a-zA-Z_]\w*[?!]?)\s*(?:\([^()]*\))?\s*\z/, 1]
375
+ end
376
+
377
+ # The class tests observed the receiver's trailing method returning,
378
+ # or nil. [Class, "Weather#summary → String (observed in unit tests)"]
379
+ def observed_receiver_class(bind, receiver)
380
+ name = trailing_call_name(receiver)
381
+ entries = name && observed_for(name)
382
+ return nil unless entries
383
+
384
+ ret = entries.flat_map { |o| o['returns'].keys }.tally.max_by { |_, count| count }&.first
385
+ klass = begin
386
+ ret && bind.eval(ret)
387
+ rescue Exception # rubocop:disable Lint/RescueException
388
+ nil
389
+ end
390
+ return nil unless klass.is_a?(Module)
391
+
392
+ [klass, "#{entries.first['owner']}##{name} → #{ret} (observed in unit tests)"]
393
+ end
394
+
395
+ # Completion fallback when the receiver can't be evaluated (mocked
396
+ # network, unreached code path): if tests observed the trailing method
397
+ # of the receiver expression, complete on its most-seen return class.
398
+ def oracle_complete(bind, receiver, prefix)
399
+ klass, via = observed_receiver_class(bind, receiver)
400
+ return nil unless klass
401
+
402
+ ancestors = klass.ancestors
403
+ names = klass.instance_methods.map(&:to_s)
404
+ names.select! { |n| n.start_with?(prefix) } unless prefix.empty?
405
+ items = names.sort.first(MAX_ITEMS).map do |n|
406
+ item = { 'name' => n, 'kind' => 'method' }
407
+ m = begin
408
+ klass.instance_method(n)
409
+ rescue StandardError
410
+ nil
411
+ end
412
+ if m
413
+ item['signature'] = signature(n, m)
414
+ item['owner'] = m.owner.to_s
415
+ item['sort'] = ancestors.index(m.owner) || 98
416
+ end
417
+ item
418
+ end
419
+ { 'items' => items, 'receiver_class' => klass.to_s, 'oracle' => true,
420
+ 'oracle_source' => via }
421
+ end
422
+
423
+ # -------------------------------------------------------------------- run
424
+
425
+ # Execute the whole buffer under a TracePoint, capturing local-variable
426
+ # state per line — the "as if you were sitting in a debugger" view.
427
+ def run(req)
428
+ code = req['code'].to_s
429
+ bind = fresh_binding
430
+ line_states = {}
431
+ prev_line = nil
432
+ events = 0
433
+
434
+ tp = TracePoint.new(:line) do |t|
435
+ next unless t.path == sandbox_path
436
+
437
+ events += 1
438
+ next if events > MAX_TRACE_EVENTS
439
+
440
+ # A :line event fires *before* its line runs, so its state describes
441
+ # the world *after* the previously-seen line.
442
+ line_states[prev_line] = capture_locals(t.binding) if prev_line
443
+ prev_line = t.lineno
444
+ end
445
+
446
+ error = nil
447
+ begin
448
+ tp.enable { bind.eval(code, sandbox_path) }
449
+ rescue Exception => e # rubocop:disable Lint/RescueException
450
+ error = describe_error(e)
451
+ ensure
452
+ tp.disable
453
+ end
454
+ line_states[prev_line] = capture_locals(bind) if prev_line
455
+
456
+ { 'line_states' => line_states,
457
+ 'final_locals' => capture_locals(bind),
458
+ 'error' => error,
459
+ 'truncated' => events > MAX_TRACE_EVENTS || nil }.compact
460
+ end
461
+
462
+ def capture_locals(b)
463
+ b.local_variables.each_with_object({}) do |v, h|
464
+ val = b.local_variable_get(v)
465
+ h[v.to_s] = { 'class' => class_of(val).to_s, 'value' => safe_inspect(val) }
466
+ end
467
+ rescue Exception # rubocop:disable Lint/RescueException
468
+ {}
469
+ end
470
+
471
+ # ---------------------------------------------------------------- helpers
472
+
473
+ def safe_method(obj, name)
474
+ obj.method(name)
475
+ rescue Exception # rubocop:disable Lint/RescueException
476
+ begin
477
+ class_of(obj).instance_method(name)
478
+ rescue Exception # rubocop:disable Lint/RescueException
479
+ nil
480
+ end
481
+ end
482
+
483
+ def class_of(obj)
484
+ ::Kernel.instance_method(:class).bind_call(obj)
485
+ end
486
+
487
+ # "gsub(pattern, replacement = …, &block)" from Method#parameters.
488
+ # C-implemented methods often report anonymous params — synthesise names.
489
+ def signature(name, method)
490
+ params = method.parameters
491
+ counter = 0
492
+ parts = params.map do |type, pname|
493
+ counter += 1
494
+ n = pname || "arg#{counter}"
495
+ case type
496
+ when :req then n.to_s
497
+ when :opt then "#{n} = …"
498
+ when :rest then "*#{pname || 'args'}"
499
+ when :keyreq then "#{n}:"
500
+ when :key then "#{n}: …"
501
+ when :keyrest then "**#{pname || 'opts'}"
502
+ when :block then "&#{pname || 'block'}"
503
+ when :nokey then '**nil'
504
+ end
505
+ end.compact
506
+ if parts.empty? && method.arity != 0
507
+ n = method.arity.abs - (method.arity.negative? ? 1 : 0)
508
+ parts = (1..n).map { |i| "arg#{i}" }
509
+ parts << '*args' if method.arity.negative?
510
+ end
511
+ parts.empty? ? name.to_s : "#{name}(#{parts.join(', ')})"
512
+ end
513
+
514
+ def safe_inspect(val)
515
+ truncate(val.inspect, 140)
516
+ rescue Exception # rubocop:disable Lint/RescueException
517
+ "#<#{class_of(val)} (uninspectable)>"
518
+ end
519
+
520
+ def truncate(s, len)
521
+ s = s.to_s
522
+ s.length > len ? "#{s[0, len - 1]}…" : s
523
+ end
524
+ end
525
+ end
526
+
527
+ # Top-level def so `self` inside the sandbox is `main`, exactly like a script.
528
+ def __ruby_ide_sandbox__
529
+ binding
530
+ end
531
+
532
+ RubyTestIDE.main