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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +218 -0
- data/exe/ruby-test-ide +42 -0
- data/lib/ruby_test_ide/observer.rb +97 -0
- data/lib/ruby_test_ide/public/index.html +611 -0
- data/lib/ruby_test_ide/public/vendor/monaco/LICENSE +21 -0
- data/lib/ruby_test_ide/public/vendor/monaco/ThirdPartyNotices.txt +448 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.de.js +8 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.es.js +8 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.fr.js +8 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.it.js +8 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.ja.js +8 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.js +8 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.ko.js +8 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.ru.js +8 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.zh-cn.js +8 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/base/common/worker/simpleWorker.nls.zh-tw.js +8 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/base/worker/workerMain.js +27 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.css +6 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.js +745 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.de.js +31 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.es.js +31 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.fr.js +29 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.it.js +29 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.ja.js +31 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.js +29 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.ko.js +29 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.ru.js +31 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.zh-cn.js +31 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/editor/editor.main.nls.zh-tw.js +29 -0
- data/lib/ruby_test_ide/public/vendor/monaco/vs/loader.js +11 -0
- data/lib/ruby_test_ide/runner.rb +532 -0
- data/lib/ruby_test_ide/server.rb +724 -0
- data/lib/ruby_test_ide/version.rb +5 -0
- data/lib/ruby_test_ide.rb +12 -0
- 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
|