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,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
|