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,724 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # RubyTestIDE proof-of-concept server.
5
+ #
6
+ # Zero-gem HTTP server (plain TCPServer) that powers a browser IDE with
7
+ # *runtime* Ruby intelligence: every completion/hover/run request spawns a
8
+ # short-lived worker (lib/runner.rb) that actually evaluates the buffer in a
9
+ # sandbox binding and reflects on the live objects — the same trick irb uses.
10
+ #
11
+ # ruby server.rb [port] # default port 3000, then open http://localhost:3000
12
+ #
13
+ # Endpoints (all JSON):
14
+ # POST /api/complete {code, line, column} -> {items:[...], receiver, prefix, receiver_class}
15
+ # POST /api/hover {code, line, column, word, wordStartColumn} -> {kind, signature, doc, ...}
16
+ # POST /api/diagnostics {code} -> {diagnostics:[{line, message}]}
17
+ # POST /api/run {code} -> {stdout, line_states, final_locals, error}
18
+ # GET /health -> ok
19
+
20
+ require 'socket'
21
+ require 'json'
22
+ require 'stringio'
23
+ require 'uri'
24
+
25
+ module RubyTestIDE
26
+ ROOT = __dir__
27
+ RUNNER = File.join(ROOT, 'runner.rb')
28
+ RUNNER_TIMEOUT = 4 # seconds of wall clock before the worker is killed
29
+ # The project directory being edited. The `ruby-test-ide` executable sets
30
+ # the env var to --workspace (default: the directory it was launched from).
31
+ WORKSPACE = File.expand_path(ENV['RUBY_TEST_IDE_WORKSPACE'] || Dir.pwd)
32
+
33
+ # The server executes arbitrary code from any request it accepts, so every
34
+ # route except /health and /vendor/* (static library assets, no user data)
35
+ # requires this token — set by the `ruby-test-ide` executable, which
36
+ # generates a random one per launch unless --token/--no-auth override it.
37
+ AUTH_DISABLED = ENV['RUBY_TEST_IDE_NO_AUTH'] == '1'
38
+ TOKEN = ENV['RUBY_TEST_IDE_TOKEN'].to_s
39
+
40
+ # Constant-time compare — cheap insurance on localhost, real insurance if
41
+ # this ever ends up reachable from elsewhere (e.g. --workspace over a VPN).
42
+ def self.token_matches?(candidate)
43
+ return true if AUTH_DISABLED
44
+ return false if candidate.nil? || TOKEN.empty?
45
+
46
+ a = candidate.to_s.b
47
+ b = TOKEN.b
48
+ return false unless a.bytesize == b.bytesize
49
+
50
+ result = 0
51
+ a.bytes.each_with_index { |byte, i| result |= byte ^ b.getbyte(i) }
52
+ result.zero?
53
+ end
54
+ WORKSPACE_IGNORE = %r{(\A|/)(\.git|node_modules|tmp|\.bundle|\.ruby-ide)(/|\z)}
55
+ # The sidebar is a Ruby IDE, not a general file browser: showing every
56
+ # file (Gemfile.lock, README, dotfiles) invited opening a non-Ruby file,
57
+ # which the editor still force-parsed as Ruby and reported bogus syntax
58
+ # errors on. Extensionless Ruby files are named explicitly.
59
+ RUBY_FILENAMES = %w[Gemfile Rakefile].freeze
60
+ TEST_GLOBS = ['test/**/*_test.rb', 'test/**/test_*.rb', 'spec/**/*_spec.rb'].freeze
61
+ LEARN_TIMEOUT = 60 # whole learn commands get a bigger budget than single evals
62
+ OBSERVATIONS = File.join(WORKSPACE, '.ruby-ide', 'observations.json')
63
+ OBSERVER = File.join(ROOT, 'observer.rb')
64
+ CONFIG_FILE = File.join(WORKSPACE, '.ruby_ide.yaml')
65
+
66
+ def self.discover_tests
67
+ TEST_GLOBS.flat_map { |g| Dir.glob(File.join(WORKSPACE, g)) }.uniq.sort
68
+ end
69
+
70
+ # .ruby_ide.yaml in the workspace root, e.g.
71
+ # learn: # any commands that execute your code
72
+ # - bundle exec rspec
73
+ # - bundle exec rake test
74
+ # - ruby scripts/exercise.rb
75
+ # timeout: 120 # optional, seconds per command
76
+ def self.load_config
77
+ return {} unless File.file?(CONFIG_FILE)
78
+
79
+ require 'yaml'
80
+ config = YAML.safe_load(File.read(CONFIG_FILE))
81
+ config.is_a?(Hash) ? config : {}
82
+ rescue StandardError => e
83
+ { 'config_error' => "#{File.basename(CONFIG_FILE)}: #{e.message}" }
84
+ end
85
+
86
+ # No config? Guess: run each minitest file / the spec dir, under bundler
87
+ # when the workspace has a Gemfile.
88
+ def self.default_learn_commands
89
+ prefix = File.file?(File.join(WORKSPACE, 'Gemfile')) ? 'bundle exec ' : ''
90
+ tests = discover_tests
91
+ specs, minitests = tests.partition { |f| f.end_with?('_spec.rb') }
92
+ commands = minitests.map { |f| "#{prefix}ruby #{f.delete_prefix("#{WORKSPACE}/")}" }
93
+ commands << "#{prefix}rspec" unless specs.empty?
94
+ commands
95
+ end
96
+
97
+ # Run one learn command with the observer preloaded into every ruby
98
+ # process it starts. Output is captured; the process group is killed on
99
+ # timeout, exactly like eval workers.
100
+ def self.run_learn_command(cmd, obs_dir, timeout)
101
+ out_r, out_w = IO.pipe
102
+ env = {
103
+ 'RUBYOPT' => [ENV['RUBYOPT'], "-r#{OBSERVER}"].compact.reject(&:empty?).join(' '),
104
+ 'RUBY_IDE_OBS_DIR' => obs_dir,
105
+ 'RUBY_IDE_OBS_ROOT' => WORKSPACE,
106
+ }
107
+ pid = spawn(env, cmd.to_s, chdir: WORKSPACE,
108
+ in: File::NULL, out: out_w, err: out_w, pgroup: true)
109
+ out_w.close
110
+ out_thread = Thread.new { out_r.read }
111
+ status, killed = wait_or_kill(pid, timeout)
112
+ output = out_thread.value
113
+ out_r.close
114
+ { 'command' => cmd, 'exit' => status&.exitstatus, 'ok' => !killed && status&.success? == true,
115
+ 'timeout' => killed || nil, 'output' => output.to_s[-4000..] || output.to_s }.compact
116
+ rescue StandardError => e
117
+ { 'command' => cmd, 'ok' => false, 'output' => "#{e.class}: #{e.message}" }
118
+ end
119
+
120
+ def self.wait_or_kill(pid, timeout)
121
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
122
+ loop do
123
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
124
+ return [status, false] if status
125
+
126
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
127
+ begin
128
+ Process.kill('-KILL', pid)
129
+ rescue StandardError
130
+ nil
131
+ end
132
+ _, status = Process.waitpid2(pid)
133
+ return [status, true]
134
+ end
135
+ sleep 0.01
136
+ end
137
+ end
138
+
139
+ # Merge the per-process observation dumps into one map (a suite may spawn
140
+ # several ruby processes; each writes its own file).
141
+ def self.merge_observations(files)
142
+ merged = {}
143
+ files.each do |f|
144
+ JSON.parse(File.read(f)).each do |key, entry|
145
+ if (existing = merged[key])
146
+ existing['calls'] += entry['calls'].to_i
147
+ entry['args'].each do |name, classes|
148
+ (existing['args'][name] ||= {}).merge!(classes) { |_, mine, _| mine }
149
+ end
150
+ existing['returns'].merge!(entry['returns']) { |_, mine, _| mine }
151
+ else
152
+ merged[key] = entry
153
+ end
154
+ end
155
+ rescue StandardError
156
+ next
157
+ end
158
+ merged
159
+ end
160
+
161
+ # Files the editor lists / opens / saves all live under WORKSPACE.
162
+ # Every relative path from the browser is resolved and fenced here.
163
+ def self.workspace_path(rel)
164
+ raise ArgumentError, 'path required' if rel.to_s.empty?
165
+
166
+ abs = File.expand_path(rel.to_s, WORKSPACE)
167
+ raise ArgumentError, 'path escapes workspace' unless abs == WORKSPACE || abs.start_with?("#{WORKSPACE}/")
168
+
169
+ abs
170
+ end
171
+
172
+ # NB: no Dir.chdir here — the cwd is process-wide and this server is
173
+ # multi-threaded, so relative globbing would race between requests.
174
+ def self.workspace_files
175
+ Dir.glob(File.join(WORKSPACE, '**', '*'))
176
+ .select { |f| File.file?(f) }
177
+ .map { |f| f.delete_prefix("#{WORKSPACE}/") }
178
+ .reject { |f| f.match?(WORKSPACE_IGNORE) }
179
+ .select { |f| f.end_with?('.rb') || RUBY_FILENAMES.include?(File.basename(f)) }
180
+ .sort
181
+ .map { |f| { 'path' => f, 'size' => File.size(File.join(WORKSPACE, f)) } }
182
+ end
183
+
184
+ def self.seed_workspace
185
+ require 'fileutils'
186
+ FileUtils.mkdir_p(WORKSPACE)
187
+ return unless Dir.empty?(WORKSPACE)
188
+
189
+ FileUtils.mkdir_p(File.join(WORKSPACE, 'lib'))
190
+ File.write(File.join(WORKSPACE, 'main.rb'), <<~RUBY)
191
+ # Files here are real: open them from the sidebar, edit, save with Cmd+S.
192
+ # require_relative works — and so does reflection ACROSS files:
193
+ # type greeter. below and methods from lib/greeter.rb appear.
194
+
195
+ require_relative 'lib/greeter'
196
+
197
+ greeter = Greeter.new('Sam')
198
+ message = greeter.greet
199
+ puts message
200
+ RUBY
201
+ File.write(File.join(WORKSPACE, 'lib', 'greeter.rb'), <<~RUBY)
202
+ # Edit this class, save, and completions in main.rb update on the
203
+ # next keystroke — the runtime is the single source of truth.
204
+ class Greeter
205
+ attr_reader :name
206
+
207
+ def initialize(name)
208
+ @name = name
209
+ end
210
+
211
+ def greet(punctuation = '!')
212
+ "Hello, \#{name}\#{punctuation}"
213
+ end
214
+ end
215
+ RUBY
216
+ end
217
+
218
+ # ------------------------------------------------------------------ workers
219
+
220
+ # Spawn lib/runner.rb, hand it one JSON request on stdin, read the JSON
221
+ # reply from fd 3 (so user code writing to stdout can't corrupt it), and
222
+ # kill the whole process group if it blows the time budget.
223
+ def self.run_worker(payload, timeout: RUNNER_TIMEOUT)
224
+ stdin_r, stdin_w = IO.pipe
225
+ out_r, out_w = IO.pipe
226
+ result_r, result_w = IO.pipe
227
+ pid = spawn(RbConfig.ruby, '--disable-gems', RUNNER,
228
+ in: stdin_r, out: out_w, err: out_w, 3 => result_w,
229
+ pgroup: true, rlimit_cpu: timeout * 2)
230
+ stdin_r.close
231
+ out_w.close
232
+ result_w.close
233
+ stdin_w.write(JSON.generate(payload))
234
+ stdin_w.close
235
+
236
+ out_thread = Thread.new { out_r.read }
237
+ result_thread = Thread.new { result_r.read }
238
+
239
+ _status, killed = wait_or_kill(pid, timeout)
240
+
241
+ user_output = out_thread.value
242
+ raw = result_thread.value
243
+ out_r.close
244
+ result_r.close
245
+
246
+ if killed
247
+ return { 'error' => "evaluation timed out after #{timeout}s (infinite loop?)",
248
+ 'timeout' => true, 'stdout' => user_output }
249
+ end
250
+
251
+ response = begin
252
+ JSON.parse(raw)
253
+ rescue StandardError
254
+ { 'error' => "runner crashed: #{user_output.to_s[0, 500]}" }
255
+ end
256
+ response['stdout'] = user_output[0, 20_000] unless user_output.to_s.empty?
257
+ response
258
+ end
259
+
260
+ # ----------------------------------------------------- cursor text analysis
261
+
262
+ # Split "everything on the line before the cursor" into [receiver, prefix]:
263
+ # "t.st" -> ["t", "st"]
264
+ # "x = 'foo'.up" -> ["'foo'", "up"]
265
+ # "arr[0].e" -> ["arr[0]", "e"]
266
+ # "t.strip.rev" -> ["t.strip", "rev"]
267
+ # "pu" -> [nil, "pu"]
268
+ def self.split_target(text)
269
+ i = text.length
270
+ j = i
271
+ j -= 1 while j.positive? && text[j - 1].match?(/[A-Za-z0-9_]/)
272
+ prefix = text[j...i]
273
+
274
+ if j.positive? && text[j - 1] == '.' && !(j > 1 && text[j - 2] == '.')
275
+ recv_end = j - 1
276
+ recv_start = receiver_start(text, recv_end)
277
+ return [nil, prefix] if recv_start >= recv_end
278
+
279
+ [text[recv_start...recv_end], prefix]
280
+ else
281
+ # pull sigils (@, @@, $) into the prefix for ivar/gvar completion
282
+ j -= 1 while j.positive? && text[j - 1].match?(/[@$]/)
283
+ [nil, text[j...i]]
284
+ end
285
+ end
286
+
287
+ # Scan backwards from the trailing '.' to find where the receiver
288
+ # expression starts, honouring balanced brackets and string literals.
289
+ def self.receiver_start(text, pos)
290
+ i = pos
291
+ while i.positive?
292
+ c = text[i - 1]
293
+ case c
294
+ when /[A-Za-z0-9_?!@$]/
295
+ i -= 1
296
+ when ')', ']', '}'
297
+ open = matching_open(text, i - 1)
298
+ return i unless open
299
+
300
+ i = open
301
+ when '"', "'"
302
+ k = i - 2
303
+ k -= 1 while k >= 0 && !(text[k] == c && (k.zero? || text[k - 1] != '\\'))
304
+ return i if k.negative?
305
+
306
+ i = k
307
+ when '.'
308
+ return i if i >= 2 && text[i - 2] == '.' # don't cross a range operator
309
+
310
+ i -= 1
311
+ when ':'
312
+ i -= 1
313
+ else
314
+ return i
315
+ end
316
+ end
317
+ 0
318
+ end
319
+
320
+ def self.matching_open(text, close_idx)
321
+ pairs = { ')' => '(', ']' => '[', '}' => '{' }
322
+ close = text[close_idx]
323
+ open = pairs[close]
324
+ depth = 0
325
+ close_idx.downto(0) do |k|
326
+ depth += 1 if text[k] == close
327
+ depth -= 1 if text[k] == open
328
+ return k if depth.zero?
329
+ end
330
+ nil
331
+ end
332
+
333
+ # --------------------------------------------------------------- diagnostics
334
+
335
+ # Compile (never execute) the buffer to catch syntax errors — on 3.2,
336
+ # AbstractSyntaxTree.parse omits line numbers. Two message formats exist:
337
+ # <= 3.3 (parse.y): "(buffer):3: syntax error, unexpected end-of-input"
338
+ # >= 3.4 (prism): "(buffer):3: syntax errors found" followed by a
339
+ # source listing with "> 3 | puts 1" markers and
340
+ # " | ^ expected an `end` ..." caret lines
341
+ def self.syntax_diagnostics(code)
342
+ stderr = $stderr
343
+ $stderr = StringIO.new
344
+ RubyVM::InstructionSequence.compile(code, '(buffer)')
345
+ []
346
+ rescue SyntaxError => e
347
+ parse_syntax_error(e.message)
348
+ ensure
349
+ $stderr = stderr
350
+ end
351
+
352
+ def self.parse_syntax_error(message)
353
+ diagnostics = []
354
+ current_line = nil
355
+ message.split("\n").each do |line|
356
+ if (m = line.match(/\A>\s*(\d+)\s*\|/)) # prism: marked source line
357
+ current_line = m[1].to_i
358
+ elsif current_line && (m = line.match(/\A\s*\|\s*\^+\s*(.+)/)) # prism: caret detail
359
+ diagnostics << { 'line' => current_line, 'message' => m[1],
360
+ 'severity' => 'error', 'source' => 'syntax' }
361
+ elsif (m = line.match(/\A\(buffer\):(\d+):\s*(.+)/)) # parse.y, or prism header
362
+ diagnostics << { 'line' => m[1].to_i, 'message' => m[2],
363
+ 'severity' => 'error', 'source' => 'syntax' }
364
+ end
365
+ end
366
+ # prefer prism's detailed caret messages over its generic header line
367
+ detailed = diagnostics.reject { |d| d['message'].start_with?('syntax error') && diagnostics.size > 1 }
368
+ detailed.empty? ? diagnostics : detailed
369
+ end
370
+
371
+ # ---------------------------------------------------------------- ri lookup
372
+
373
+ # Documentation comes from the installed ri database (same data `ri
374
+ # String#strip` uses). Method docs are fetched in-process via
375
+ # RDoc::RI::Driver (~0.15ms warm vs ~290ms for shelling out to `ri`);
376
+ # class docs and any driver failure fall back to the `ri` CLI. Memoised.
377
+ #
378
+ # If lookups return nothing, generate the core docs once:
379
+ # cd ~/.rvm/src/ruby-3.2.2 && rdoc --all --ri \
380
+ # --op ~/.rvm/rubies/ruby-3.2.2/share/ri/3.2.0/system .
381
+ class DocStore
382
+ def initialize
383
+ @cache = {}
384
+ @mutex = Mutex.new
385
+ @driver = :unloaded
386
+ end
387
+
388
+ def lookup(*names)
389
+ names.compact.uniq.each do |name|
390
+ doc = @mutex.synchronize { @cache[name] }
391
+ unless @mutex.synchronize { @cache.key?(name) }
392
+ doc = fetch(name)
393
+ @mutex.synchronize { @cache[name] = doc }
394
+ end
395
+ return doc if doc && !doc.empty?
396
+ end
397
+ nil
398
+ end
399
+
400
+ private
401
+
402
+ def fetch(name)
403
+ doc = name.match?(/[#.]/) ? driver_fetch(name) : nil
404
+ doc || shell_fetch(name)
405
+ end
406
+
407
+ def driver_fetch(name)
408
+ d = driver
409
+ return nil unless d
410
+
411
+ out = RDoc::Markup::Document.new
412
+ @mutex.synchronize { d.add_method(out, name.sub('.', '::')) }
413
+ text = out.accept(RDoc::Markup::ToMarkdown.new)
414
+ text && !text.strip.empty? ? text.strip[0, 4000] : nil
415
+ rescue StandardError
416
+ nil # NotFoundError or store hiccup — fall through to `ri` CLI
417
+ end
418
+
419
+ def driver
420
+ @mutex.synchronize do
421
+ if @driver == :unloaded
422
+ @driver = begin
423
+ require 'rdoc'
424
+ RDoc::RI::Driver.new(RDoc::RI::Driver.default_options)
425
+ rescue StandardError, LoadError
426
+ nil
427
+ end
428
+ end
429
+ @driver
430
+ end
431
+ end
432
+
433
+ def shell_fetch(name)
434
+ out = IO.popen(['ri', '-T', '--format=markdown', name], err: File::NULL, &:read)
435
+ return nil unless $?.success?
436
+
437
+ out = out.strip
438
+ out.empty? ? nil : out[0, 4000]
439
+ rescue StandardError
440
+ nil
441
+ end
442
+ end
443
+
444
+ DOCS = DocStore.new
445
+
446
+ # -------------------------------------------------------------------- http
447
+
448
+ class Server
449
+ PUBLIC = File.join(ROOT, 'public')
450
+ VENDOR = File.join(PUBLIC, 'vendor')
451
+ STATIC_CONTENT_TYPES = {
452
+ '.js' => 'text/javascript; charset=utf-8',
453
+ '.css' => 'text/css; charset=utf-8',
454
+ '.ttf' => 'font/ttf',
455
+ }.freeze
456
+
457
+ def initialize(port)
458
+ @port = port
459
+ end
460
+
461
+ def start
462
+ server = TCPServer.new('127.0.0.1', @port)
463
+ url = "http://localhost:#{@port}/"
464
+ url += "?token=#{RubyTestIDE::TOKEN}" unless RubyTestIDE::AUTH_DISABLED
465
+ puts "RubyTestIDE listening on #{url} (ruby #{RUBY_VERSION})"
466
+ # This line is the only way to learn the token when stdout is
467
+ # redirected to a file — puts alone can sit in a buffer indefinitely
468
+ # on a long-running server that never exits on its own.
469
+ $stdout.flush
470
+ if RubyTestIDE::AUTH_DISABLED
471
+ warn 'WARNING: started with --no-auth — anyone who can reach this port can execute code as you.'
472
+ end
473
+ loop do
474
+ client = server.accept
475
+ Thread.new(client) do |sock|
476
+ handle_client(sock)
477
+ rescue StandardError => e
478
+ warn "client error: #{e.class}: #{e.message}"
479
+ ensure
480
+ begin
481
+ sock.close
482
+ rescue StandardError
483
+ nil
484
+ end
485
+ end
486
+ end
487
+ end
488
+
489
+ def handle_client(sock)
490
+ request_line = sock.gets
491
+ return unless request_line
492
+
493
+ verb, path, = request_line.split
494
+ headers = {}
495
+ while (line = sock.gets) && line != "\r\n"
496
+ k, v = line.split(': ', 2)
497
+ headers[k.to_s.downcase] = v.to_s.strip
498
+ end
499
+ body = headers['content-length'] ? sock.read(headers['content-length'].to_i) : ''
500
+
501
+ status, ctype, payload, cache = route(verb, path, body, headers)
502
+ sock.write("HTTP/1.1 #{status}\r\n" \
503
+ "Content-Type: #{ctype}\r\n" \
504
+ "Content-Length: #{payload.bytesize}\r\n" \
505
+ "Cache-Control: #{cache || 'no-store'}\r\n" \
506
+ "Connection: close\r\n\r\n")
507
+ sock.write(payload)
508
+ end
509
+
510
+ def route(verb, full_path, body, headers)
511
+ path, query = full_path.to_s.split('?', 2)
512
+ params = query ? URI.decode_www_form(query).to_h : {}
513
+
514
+ # /health (used by process supervisors / readiness probes) and
515
+ # /vendor/* (the vendored Monaco library — no user data) never need
516
+ # the token. Everything else does: this server evaluates arbitrary
517
+ # code and reads/writes the workspace on request.
518
+ case [verb, path]
519
+ in ['GET', '/health']
520
+ return ['200 OK', 'application/json', '{"ok":true}']
521
+ in ['GET', %r{\A/vendor/}]
522
+ return serve_static(path)
523
+ else
524
+ # falls through to the auth-gated routes below
525
+ end
526
+
527
+ token = headers['x-ruby-test-ide-token'] || params['token']
528
+ return unauthorized(html: path == '/' || path == '/index.html') unless RubyTestIDE.token_matches?(token)
529
+
530
+ case [verb, path]
531
+ in ['GET', '/'] | ['GET', '/index.html']
532
+ # Cache-bust vendored assets with the gem version: serve_static sets
533
+ # a 1-year immutable cache, so a stale browser cache across a gem
534
+ # upgrade would otherwise serve last version's Monaco forever. The
535
+ # token is only embedded here because we've just verified it above —
536
+ # the client then sends it back as a header on every /api/* call.
537
+ html = File.read(File.join(PUBLIC, 'index.html'))
538
+ .gsub('__RTI_VERSION__', RubyTestIDE::VERSION)
539
+ .gsub('__RTI_TOKEN__', RubyTestIDE::TOKEN)
540
+ ['200 OK', 'text/html; charset=utf-8', html]
541
+ in ['GET', '/api/files'] then json { { 'files' => RubyTestIDE.workspace_files } }
542
+ in ['GET', '/api/file'] then json { api_read_file(params) }
543
+ in ['POST', '/api/file'] then json { api_save_file(parse(body)) }
544
+ in ['POST', '/api/complete'] then json { api_complete(parse(body)) }
545
+ in ['POST', '/api/hover'] then json { api_hover(parse(body)) }
546
+ in ['POST', '/api/diagnostics'] then json { api_diagnostics(parse(body)) }
547
+ in ['POST', '/api/run'] then json { api_run(parse(body)) }
548
+ in ['POST', '/api/learn'] then json { api_learn }
549
+ else
550
+ ['404 Not Found', 'text/plain', 'not found']
551
+ end
552
+ rescue Errno::ENOENT
553
+ ['404 Not Found', 'text/plain', 'not found']
554
+ end
555
+
556
+ def unauthorized(html:)
557
+ if html
558
+ ['401 Unauthorized', 'text/plain; charset=utf-8',
559
+ "401 Unauthorized\n\n" \
560
+ "Missing or invalid token. Open the URL printed in the terminal " \
561
+ "where you ran ruby-test-ide (it includes ?token=...).\n"]
562
+ else
563
+ ['401 Unauthorized', 'application/json; charset=utf-8',
564
+ JSON.generate('error' => 'unauthorized: missing or invalid token')]
565
+ end
566
+ end
567
+
568
+ def parse(body)
569
+ JSON.parse(body.to_s)
570
+ rescue StandardError
571
+ {}
572
+ end
573
+
574
+ # Serves vendored front-end assets (Monaco) so the IDE works with no
575
+ # internet access beyond the initial `gem install`. Content is fixed at
576
+ # gem-build time and keyed by RubyTestIDE::VERSION in the URL the
577
+ # browser requests, so it's safe to cache indefinitely.
578
+ def serve_static(path)
579
+ rel = path.delete_prefix('/vendor/')
580
+ abs = File.expand_path(File.join(VENDOR, rel))
581
+ return ['404 Not Found', 'text/plain', 'not found'] unless abs.start_with?("#{VENDOR}/") && File.file?(abs)
582
+
583
+ ctype = STATIC_CONTENT_TYPES[File.extname(abs)] || 'application/octet-stream'
584
+ ['200 OK', ctype, File.binread(abs), 'public, max-age=31536000, immutable']
585
+ end
586
+
587
+ def json
588
+ payload = yield
589
+ ['200 OK', 'application/json; charset=utf-8', JSON.generate(payload)]
590
+ rescue StandardError => e
591
+ ['500 Internal Server Error', 'application/json',
592
+ JSON.generate('error' => "#{e.class}: #{e.message}")]
593
+ end
594
+
595
+ # ---- endpoints
596
+
597
+ def api_read_file(params)
598
+ abs = RubyTestIDE.workspace_path(params['path'])
599
+ raise Errno::ENOENT, params['path'] unless File.file?(abs)
600
+
601
+ { 'path' => params['path'], 'content' => File.read(abs) }
602
+ end
603
+
604
+ def api_save_file(req)
605
+ abs = RubyTestIDE.workspace_path(req['path'])
606
+ require 'fileutils'
607
+ FileUtils.mkdir_p(File.dirname(abs))
608
+ File.write(abs, req['content'].to_s)
609
+ { 'ok' => true, 'path' => req['path'], 'size' => File.size(abs) }
610
+ end
611
+
612
+ # The file's real location, so the worker can chdir there and make
613
+ # require_relative resolve against the workspace.
614
+ def file_context(req)
615
+ abs = RubyTestIDE.workspace_path(req['path'])
616
+ { 'path' => abs, 'dir' => File.dirname(abs), 'root' => WORKSPACE }
617
+ rescue ArgumentError
618
+ {}
619
+ end
620
+
621
+ def api_complete(req)
622
+ code = req['code'].to_s
623
+ lines = code.split("\n", -1)
624
+ line = req['line'].to_i # 1-based (monaco convention)
625
+ column = req['column'].to_i # 1-based
626
+ before = (lines[line - 1] || '')[0, [column - 1, 0].max]
627
+ receiver, prefix = RubyTestIDE.split_target(before.to_s)
628
+ code_before = lines[0, line - 1].join("\n")
629
+
630
+ result = RubyTestIDE.run_worker(
631
+ { 'op' => 'complete', 'code_before' => code_before,
632
+ 'receiver' => receiver, 'prefix' => prefix }.merge(file_context(req)),
633
+ timeout: RUNNER_TIMEOUT
634
+ )
635
+ result.merge('receiver' => receiver, 'prefix' => prefix)
636
+ end
637
+
638
+ def api_hover(req)
639
+ code = req['code'].to_s
640
+ lines = code.split("\n", -1)
641
+ line = req['line'].to_i
642
+ word = req['word'].to_s
643
+ word_start = req['wordStartColumn'].to_i
644
+ before_word = (lines[line - 1] || '')[0, [word_start - 1, 0].max].to_s
645
+
646
+ receiver = nil
647
+ if before_word.end_with?('.') && !before_word.end_with?('..')
648
+ start = RubyTestIDE.receiver_start(before_word, before_word.length - 1)
649
+ receiver = before_word[start...-1]
650
+ receiver = nil if receiver.to_s.strip.empty?
651
+ end
652
+ # For hover, context = everything up to and including the previous line,
653
+ # plus the current line itself when it parses on its own.
654
+ code_before = lines[0, line - 1].join("\n")
655
+
656
+ result = RubyTestIDE.run_worker(
657
+ { 'op' => 'hover', 'code_before' => code_before,
658
+ 'receiver' => receiver, 'name' => word }.merge(file_context(req)),
659
+ timeout: RUNNER_TIMEOUT
660
+ )
661
+
662
+ if (src = result['source']) && src.start_with?("#{WORKSPACE}/")
663
+ result['source_rel'] = src.delete_prefix("#{WORKSPACE}/")
664
+ end
665
+ if result['kind'] == 'method'
666
+ result['doc'] = DOCS.lookup(
667
+ "#{result['owner']}##{word}",
668
+ "#{result['receiver_class']}##{word}",
669
+ "#{result['owner']}.#{word}"
670
+ )
671
+ elsif %w[variable constant class].include?(result['kind'])
672
+ result['doc'] = DOCS.lookup(result['kind'] == 'class' ? result['name'] : result['class'])
673
+ end
674
+ result
675
+ end
676
+
677
+ def api_diagnostics(req)
678
+ { 'diagnostics' => RubyTestIDE.syntax_diagnostics(req['code'].to_s) }
679
+ end
680
+
681
+ def api_run(req)
682
+ RubyTestIDE.run_worker({ 'op' => 'run', 'code' => req['code'].to_s }.merge(file_context(req)),
683
+ timeout: RUNNER_TIMEOUT)
684
+ end
685
+
686
+ # Run the configured learn commands (any test framework or script) with
687
+ # the observer preloaded, then persist the merged type observations —
688
+ # completion/hover consult these when live eval can't help.
689
+ def api_learn
690
+ config = RubyTestIDE.load_config
691
+ return { 'error' => config['config_error'] } if config['config_error']
692
+
693
+ commands = Array(config['learn'] || RubyTestIDE.default_learn_commands).map(&:to_s)
694
+ if commands.empty?
695
+ return { 'error' => 'nothing to run: add learn: commands to ' \
696
+ '.ruby_ide.yaml or create test/**/*_test.rb / spec files' }
697
+ end
698
+ timeout = (config['timeout'] || LEARN_TIMEOUT).to_i.clamp(1, 600)
699
+
700
+ require 'tmpdir'
701
+ require 'fileutils'
702
+ merged = nil
703
+ results = nil
704
+ Dir.mktmpdir('ruby-ide-obs') do |obs_dir|
705
+ results = commands.map { |cmd| RubyTestIDE.run_learn_command(cmd, obs_dir, timeout) }
706
+ merged = RubyTestIDE.merge_observations(Dir.glob(File.join(obs_dir, 'obs-*.json')))
707
+ end
708
+ unless merged.empty?
709
+ FileUtils.mkdir_p(File.dirname(OBSERVATIONS))
710
+ File.write(OBSERVATIONS, JSON.generate(merged))
711
+ end
712
+ { 'commands' => results,
713
+ 'passed' => results.all? { |r| r['ok'] },
714
+ 'methods_observed' => merged.size }
715
+ end
716
+ end
717
+ end
718
+
719
+ module RubyTestIDE
720
+ def self.start(port: 3000)
721
+ seed_workspace
722
+ Server.new(port).start
723
+ end
724
+ end