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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8573fe0b8988789da6eabe7b6baf637a1202836f754c3a42a2bd96382bccd9b5
4
+ data.tar.gz: 53184e3a3e26f9d2f68380bca1f7c4bbc61f0abdf0e7d847c541df3e519c22be
5
+ SHA512:
6
+ metadata.gz: e0892bf3f0e8357194dc3642aae916be6b758ae8ef9a568a1cf2850481cc97beabc5963cdc8603696c838877dfbb41c6bc9e0424b8d92f8e1253259a7ea660c0
7
+ data.tar.gz: a20ca8a9aad2c16cb30d9be04c0efde597d8199c32cfeb81ecde8e5278355069c5991b80874eeab2912f94ae1486f54ed0993d20583bd9635ccd89ba11569403
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samuel Garratt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # ruby-test-ide
2
+
3
+ A browser IDE for Ruby whose intelligence comes from **running your code and
4
+ asking the live objects what they can do** — the same trick `irb` uses —
5
+ plus argument/return types **observed while your unit tests run**, instead of
6
+ static analysis or type-annotation files (sorbet/RBS).
7
+
8
+ ```
9
+ t = 'test' # evaluated for real, in a sandboxed worker process
10
+ t.st # completion asks the actual String instance:
11
+ # -> start_with? strip strip! (with arity + owner)
12
+ ```
13
+
14
+ ## Run it
15
+
16
+ ```bash
17
+ gem install ruby-test-ide
18
+ cd your/ruby/project
19
+ ruby-test-ide # prints e.g. http://localhost:3000/?token=<random> — open that
20
+ ruby-test-ide -p 4000 -w path/to/project # or pick port/workspace
21
+ ```
22
+
23
+ The server executes arbitrary code from any request it accepts, so a random
24
+ access token is required by default — copy the full URL it prints (including
25
+ `?token=...`); the page then attaches that token to every API call itself,
26
+ so you won't need to touch the URL again for that session. `--no-auth`
27
+ disables the token for CI/automation; `--token yourvalue` pins a fixed one.
28
+
29
+ From this repo (development):
30
+
31
+ ```bash
32
+ ruby exe/ruby-test-ide --workspace workspace # demo workspace (prints its own token)
33
+ rake test # unit tests (pure helpers, worker round-trip, auth gate)
34
+ rake e2e # browser suite (Playwright, isolated fixture workspace, --no-auth)
35
+ rake build # pkg/ruby-test-ide-x.y.z.gem
36
+ rake install # build + install locally
37
+ rake release # tag vX.Y.Z, push, publish to rubygems (maintainer only)
38
+ ```
39
+
40
+ Zero runtime dependencies (TCPServer, RDoc/ri and TracePoint are stdlib);
41
+ ruby >= 3.2, verified on 3.2.2 and 3.4.9 — syntax diagnostics understand both
42
+ parser generations' error formats (3.4's Prism messages are richer and
43
+ surface as better squiggles).
44
+
45
+ The sidebar shows a collapsible folder tree (state persists across reloads)
46
+ of Ruby files (`*.rb`, `Gemfile`, `Rakefile` — not a general file browser, so
47
+ opening something never force-parses non-Ruby content as Ruby), is resizable
48
+ by dragging its right edge, opens files, `+` creates them, Cmd/Ctrl+S saves;
49
+ unsaved buffers keep their edits when you switch files and are marked with a
50
+ dot. All paths are fenced inside the workspace root (traversal rejected
51
+ server-side). An empty directory is seeded with a two-file example on first
52
+ boot.
53
+
54
+ What you get in the editor:
55
+
56
+ | Goal (CLAUDE.md) | How it works here |
57
+ |---|---|
58
+ | Autocomplete of all methods + parameter counts | On `.`, the buffer up to the cursor is evaluated in a worker; the receiver expression is `eval`'d against that binding and its **live methods** are listed with `Method#parameters`-derived signatures, arity and owner class. Works for chains (`t.strip.rev`), literals (`[1,2].su`), hash elements (`person[:name].cap`), user-defined methods, locals, constants, keywords. |
59
+ | Ruby docs on hover | Hovering a method looks up the real `ri` database in-process (`RDoc::RI::Driver`, memoised, ~0.15 ms warm). Hovering a variable shows its class **and current value**. |
60
+ | Error highlighting | Syntax errors (compile-only check, never executed) and runtime errors (from live eval, with the failing line) become Monaco squiggles. |
61
+ | `irb`-grade experience | Better: with **Live eval** on, every edit re-runs the buffer under a `TracePoint`, so each line gets a grey debugger annotation of the locals it changed (`# t: String = "test"`), and the side panel shows the final state of every variable + captured stdout. |
62
+ | Go to definition | Cmd/Ctrl+click or F12 on any method jumps to its `Method#source_location` — the runtime answer, so it works for methods defined dynamically. Workspace files open in place; gem/core locations are shown in the status bar. |
63
+ | Multi-file workspace | Buffers evaluate *in place*: the worker chdirs to the file's directory and shims `require_relative`, so `main.rb` can require `lib/greeter.rb` and completion on a `Greeter` instance lists methods **defined in the other file** — signatures included, unsaved edits included. |
64
+ | Gems via Gemfile | If the workspace has a `Gemfile`, the worker activates it (`bundler/setup`) before evaluating, so `require 'rest-client'` resolves exactly as under `bundle exec` and completion/hover work on gem objects (`RestClient.` → `get(url, headers = …, &block)`). Run `bundle install` with the same ruby the server uses; a missing gem surfaces as a friendly error in the panel. |
65
+ | Types from any test framework or script | **Learn from tests** runs the commands from `.ruby_ide.yaml` (or auto-detected test files) with a type observer preloaded via `RUBYOPT` into every ruby process they start. Works with minitest, rspec, rake, cucumber, or a plain script — the IDE records, per method, the argument/return classes actually seen with sample values (`.ruby-ide/observations.json`, merged across commands and subprocesses). Completion falls back to this oracle when live eval can't produce the object — `w.current('x').` completes as `Hash` even though `current` sits on top of a mocked network call — and hover shows "Observed in unit tests: current(city: String) → Hash (e.g. {...}) — 3 calls". |
66
+ | Access control | A random token (Jupyter-style) is required on every route except `/health` and the vendored static assets, since this server executes whatever code it's sent. Checked via an `X-Ruby-Test-Ide-Token` header (attached automatically by the page once loaded) or a `?token=` query param on first load; comparison is constant-time. `--no-auth` / `--token` override it. |
67
+
68
+ `npx playwright test --project=chromium` runs the end-to-end suite
69
+ (autocomplete, live state, diagnostics) against a real browser; the config
70
+ boots its own server against an isolated fixture workspace automatically.
71
+
72
+ ## Architecture
73
+
74
+ ```
75
+ browser (lib/ruby_test_ide/public/index.html, Monaco vendored — no internet needed)
76
+ │ POST /api/complete /hover /diagnostics /run (JSON)
77
+
78
+ lib/ruby_test_ide/server.rb — zero-gem TCPServer HTTP server
79
+ │ • splits "line before cursor" into receiver expression + prefix
80
+ │ (backward scanner: balanced brackets, strings, chains, ranges)
81
+ │ • syntax check via RubyVM::InstructionSequence.compile (never executes)
82
+ │ • ri doc lookup + memoised cache (RDoc::RI::Driver in-process,
83
+ │ `ri` CLI fallback)
84
+ │ spawns per request ──────────────┐
85
+ ▼ │ stdin: one JSON request
86
+ lib/ruby_test_ide/runner.rb — worker │ fd 3: one JSON reply (user stdout
87
+ • fresh method-body binding │ can't corrupt the protocol)
88
+ (self == main, no leaked locals) │ pgroup + kill on 4s timeout
89
+ • eval(code_before, bind) — largest │ rlimit_cpu belt-and-braces
90
+ syntactically-valid prefix, keeps
91
+ partial state when code raises mid-way
92
+ • completion: eval(receiver).methods + Method#parameters
93
+ • run: TracePoint(:line) captures locals per line = debugger view
94
+ ```
95
+
96
+ Design decisions that came out of research into irb/repl_type_completor:
97
+
98
+ * **fd 3 for results, child process group, hard kill** — user code that
99
+ `puts`, forks, or loops forever cannot corrupt or hang the IDE.
100
+ * **Method-body binding as sandbox** — `TOPLEVEL_BINDING.eval('binding')`
101
+ leaks the host script's locals into completions; a `def ...; binding; end`
102
+ binding is clean and keeps script semantics (`self` is `main`).
103
+ * **`:line` TracePoint events fire *before* the line runs** — state shown
104
+ for line N is taken from the event for line N+1 (and from the binding
105
+ itself after eval returns, for the last line).
106
+ * **C-implemented core methods report `[[:rest]]`** (`gsub(*args)`) — real
107
+ parameter names for those come from the ri call-seq shown on hover.
108
+ (`Method#parameters` is fully informative for methods written in Ruby,
109
+ which includes gem code.)
110
+ * **`ENV['BUNDLER_VERSION']` is pinned before `bundler/setup`** — otherwise a
111
+ lockfile `BUNDLED WITH` an older installed bundler makes setup *re-exec*
112
+ the worker, whose stdin (the request) is already consumed.
113
+ * **Monaco's word pattern is overridden for Ruby** — the default splits
114
+ `start_with?` before the `?`, so hover/definition would query a method
115
+ that doesn't exist. Receiver evaluation for hover/completion also carries
116
+ its own 2s timeout, falling back to test observations, so hovering a chain
117
+ built on a slow network call stays responsive.
118
+ * **Monaco is vendored, not CDN-loaded** (`lib/ruby_test_ide/public/vendor/monaco`,
119
+ MIT-licensed, its own LICENSE ships alongside) — the IDE works fully offline
120
+ after `gem install`. Assets are served with a 1-year immutable cache, safe
121
+ only because `index.html` is templated at request time to cache-bust every
122
+ vendored URL with `?v=<gem version>` — an upgrade can't serve a stale
123
+ cached Monaco. A worker built from a `blob:` URL resolves `importScripts()`
124
+ against its own opaque origin, not the page's, so that URL must be
125
+ absolute (`window.location.origin`), not root-relative.
126
+ * **The token is only ever embedded into `index.html` after that request's
127
+ own token already checked out** — `/` requires `?token=...` like every
128
+ other route, so there's no window where an unauthenticated request could
129
+ see it. HTTP header names are case-insensitive but not hyphen-insensitive:
130
+ `X-RubyTestIDE-Token` downcases to `x-rubytestide-token` (no internal
131
+ hyphens), which silently failed against a hand-written
132
+ `x-ruby-test-ide-token` check — caught by a test that reads the real
133
+ header name out of `index.html` and drives it through `Server#route`,
134
+ rather than hand-copying the same literal on both sides.
135
+
136
+ ## Docs prerequisite
137
+
138
+ Hover docs need an ri database. rvm rubies often ship without one; generate it
139
+ once (~20 s) with:
140
+
141
+ ```bash
142
+ cd ~/.rvm/src/ruby-3.2.2 && rdoc --all --ri \
143
+ --op ~/.rvm/rubies/ruby-3.2.2/share/ri/3.2.0/system .
144
+ ```
145
+
146
+ Gems installed with the `--no-document` default have no ri data; add
147
+ `gem: --document=ri` to `~/.gemrc`, or backfill a gem with
148
+ `rdoc --ri --op "$(gem spec NAME doc_dir 2>/dev/null || echo $GEM_HOME/doc/NAME-VERSION)/ri" $GEM_HOME/gems/NAME-VERSION/lib`.
149
+
150
+ ## Honest limitations (it's a PoC)
151
+
152
+ * **It executes your code.** That is the point — but treat it like running
153
+ `ruby file.rb`: file/network side effects happen (worker is killed after
154
+ 4 s, though, so infinite loops are fine). A "Live eval" toggle turns
155
+ continuous execution off.
156
+ * Completion inside an unfinished `def`/block falls back to the largest
157
+ parseable prefix, so method-local variables aren't visible until the
158
+ block closes (irb has the same blind spot mid-edit).
159
+ * Each request pays worker spawn cost (~50 ms) plus a full re-run of the
160
+ buffer — fine for scripts, wrong for slow/side-effectful programs.
161
+
162
+ ## How "learn from tests" works
163
+
164
+ Configure it in `workspace/.ruby_ide.yaml` — any executable counts, because
165
+ the observer doesn't run your tests, it *rides along* in them:
166
+
167
+ ```yaml
168
+ learn:
169
+ - bundle exec rspec
170
+ - bundle exec rake test
171
+ - ruby scripts/exercise_everything.rb
172
+ timeout: 60 # seconds per command
173
+ ```
174
+
175
+ ```
176
+ POST /api/learn
177
+ server: read .ruby_ide.yaml learn: commands
178
+ (fallback: guess from test/**/*_test.rb and spec/)
179
+ each command runs from the workspace root with
180
+ RUBYOPT="-r .../observer.rb" RUBY_IDE_OBS_DIR RUBY_IDE_OBS_ROOT
181
+ observer (framework-agnostic, inherited by every ruby subprocess):
182
+ TracePoint(:call, :return) filtered to workspace paths
183
+ → per method: {args: {name: {Class: sample}}, returns: {Class: sample}, calls}
184
+ → dumped to one JSON file per process at exit
185
+ server: merge dumps → workspace/.ruby-ide/observations.json
186
+ completion/hover workers read the observations file:
187
+ - receiver eval fails → trailing method name → most-seen return class
188
+ → complete on that class's instance_methods (flagged "observed in tests")
189
+ - hover merges live Method#parameters with observed types + sample values;
190
+ methods with no live object still get a full hover from observations
191
+ ```
192
+
193
+ Mocks are the mechanism, not a problem: tests stub the network edge
194
+ (`def @weather.fetch_json(_) = SAMPLE`), so everything *above* the stub runs
195
+ for real and the recorded types are real. The observer registers its
196
+ `at_exit` before minitest/rspec autorun hooks, so it dumps after the suite
197
+ finishes; paths like `<internal:hash>` are excluded so core methods don't
198
+ pollute the data. Note: `learn:` commands are arbitrary shell commands run
199
+ with your privileges — the same trust level as the IDE evaluating your code.
200
+ Ruby 3.4 note: minitest is a bundled gem, so it must be listed in the
201
+ workspace Gemfile to load under bundler.
202
+
203
+ ## Where this goes next
204
+
205
+ 1. **Persist + cache context** (as CLAUDE.md suggests): keep a warm worker
206
+ per buffer, snapshot line-states, invalidate only below the edited line.
207
+ 2. **Wrap it as an LSP server** — the runner already produces everything
208
+ `textDocument/completion|hover|publishDiagnostics` need, which would make
209
+ the same engine drive VS Code natively as an extension.
210
+ 3. **Hybrid inference for un-run code paths**: seed types from the live
211
+ binding, then abstract-interpret the current expression the way
212
+ `repl_type_completor` (irb's opt-in type completor) does — no execution
213
+ of the hovered chain, RBS-aware, still runtime-grounded.
214
+ 4. **Deeper project awareness**: the file tree, per-file buffers and
215
+ cross-file requires exist now; next is rdoc generation for the user's own
216
+ lib/ (hover docs for their classes), file rename/delete, and evaluating
217
+ entry points other than the open buffer (e.g. run tests to learn types on
218
+ code paths the buffer doesn't reach).
data/exe/ruby-test-ide ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+ require 'securerandom'
6
+ require_relative '../lib/ruby_test_ide/version'
7
+
8
+ options = { port: 3000, workspace: Dir.pwd, token: nil, no_auth: false }
9
+ OptionParser.new do |opts|
10
+ opts.banner = <<~BANNER
11
+ ruby-test-ide #{RubyTestIDE::VERSION} — runtime-reflection IDE for Ruby
12
+
13
+ Serves an IDE for a project directory. Code is EXECUTED in sandboxed
14
+ workers to power completion/hover, and "Learn from tests" observes your
15
+ test runs to know types on mocked/unreached paths. A random access token
16
+ is required by default (printed on boot) — this server will run
17
+ whatever it's asked to.
18
+
19
+ Usage: ruby-test-ide [options]
20
+ BANNER
21
+ opts.on('-p', '--port PORT', Integer, 'Port to listen on (default 3000)') { |v| options[:port] = v }
22
+ opts.on('-w', '--workspace DIR', 'Project directory to serve (default: current directory)') { |v| options[:workspace] = v }
23
+ opts.on('--token TOKEN', 'Use this token instead of generating a random one') { |v| options[:token] = v }
24
+ opts.on('--no-auth', 'Disable the access token (e.g. CI) — INSECURE: anyone reaching the port can run code as you') do
25
+ options[:no_auth] = true
26
+ end
27
+ opts.on('-v', '--version', 'Print version') do
28
+ puts RubyTestIDE::VERSION
29
+ exit
30
+ end
31
+ end.parse!
32
+
33
+ # Constants in the server derive from these — set before the require.
34
+ ENV['RUBY_TEST_IDE_WORKSPACE'] = File.expand_path(options[:workspace])
35
+ if options[:no_auth]
36
+ ENV['RUBY_TEST_IDE_NO_AUTH'] = '1'
37
+ else
38
+ ENV['RUBY_TEST_IDE_TOKEN'] = options[:token] || SecureRandom.hex(24)
39
+ end
40
+
41
+ require_relative '../lib/ruby_test_ide'
42
+ RubyTestIDE.start(port: options[:port])
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # RubyIDE type observer.
4
+ #
5
+ # Preloaded (via RUBYOPT="-r.../observer.rb") into every ruby process a
6
+ # "learn" command starts — rspec, minitest, rake, a plain script, and any
7
+ # ruby subprocess those spawn (RUBYOPT is inherited). Framework-agnostic:
8
+ # it doesn't run tests, it only watches methods defined under
9
+ # RUBY_IDE_OBS_ROOT while the host process does whatever it does, then dumps
10
+ # {method => {args, returns, samples, calls}} to a JSON file in
11
+ # RUBY_IDE_OBS_DIR at exit.
12
+ #
13
+ # Registered before the host's own code loads, so its at_exit runs AFTER
14
+ # minitest/rspec autorun hooks (at_exit is LIFO) — observations include the
15
+ # full test run. Every path is rescued: this file must never be the reason
16
+ # a user's test suite fails.
17
+
18
+ begin
19
+ ruby_ide_root = ENV['RUBY_IDE_OBS_ROOT'].to_s
20
+ ruby_ide_dir = ENV['RUBY_IDE_OBS_DIR'].to_s
21
+ unless ruby_ide_root.empty? || ruby_ide_dir.empty?
22
+ require 'json'
23
+
24
+ ruby_ide_class_of = ->(obj) { ::Kernel.instance_method(:class).bind_call(obj).to_s }
25
+ ruby_ide_inspect = lambda do |val|
26
+ s = val.inspect
27
+ s.length > 140 ? "#{s[0, 139]}…" : s
28
+ rescue Exception # rubocop:disable Lint/RescueException
29
+ '(uninspectable)'
30
+ end
31
+
32
+ ruby_ide_observations = {}
33
+ # The entry script's path can be relative (`ruby test/foo_test.rb`);
34
+ # expand once per distinct path, not per event.
35
+ ruby_ide_path_cache = {}
36
+ ruby_ide_in_root = lambda do |path|
37
+ ruby_ide_path_cache.fetch(path) do
38
+ ruby_ide_path_cache[path] =
39
+ if path.start_with?('<', '(') # <internal:hash>, (eval), (irb)…
40
+ false
41
+ else
42
+ begin
43
+ File.expand_path(path).start_with?(ruby_ide_root)
44
+ rescue StandardError
45
+ false
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ ruby_ide_tp = TracePoint.new(:call, :return) do |t|
52
+ path = t.path.to_s
53
+ next unless ruby_ide_in_root.call(path)
54
+
55
+ begin
56
+ entry = ruby_ide_observations["#{t.defined_class}##{t.method_id}"] ||= {
57
+ 'method' => t.method_id.to_s, 'owner' => t.defined_class.to_s,
58
+ 'file' => path, 'line' => t.lineno, 'calls' => 0,
59
+ 'args' => {}, 'returns' => {}
60
+ }
61
+ if t.event == :call
62
+ entry['calls'] += 1
63
+ b = t.binding
64
+ t.parameters.each do |_type, pname|
65
+ next unless pname.is_a?(Symbol)
66
+
67
+ begin
68
+ val = b.local_variable_get(pname)
69
+ rescue StandardError
70
+ next
71
+ end
72
+ slot = entry['args'][pname.to_s] ||= {}
73
+ slot[ruby_ide_class_of.call(val)] ||= ruby_ide_inspect.call(val) if slot.size < 4
74
+ end
75
+ else
76
+ slot = entry['returns']
77
+ slot[ruby_ide_class_of.call(t.return_value)] ||= ruby_ide_inspect.call(t.return_value) if slot.size < 4
78
+ end
79
+ rescue Exception # rubocop:disable Lint/RescueException
80
+ # observation must never break the host process
81
+ end
82
+ end
83
+ ruby_ide_tp.enable
84
+
85
+ at_exit do
86
+ ruby_ide_tp.disable
87
+ File.write(
88
+ File.join(ruby_ide_dir, "obs-#{Process.pid}-#{Time.now.to_f.to_s.tr('.', '')}.json"),
89
+ JSON.generate(ruby_ide_observations.first(2000).to_h)
90
+ )
91
+ rescue Exception # rubocop:disable Lint/RescueException
92
+ nil
93
+ end
94
+ end
95
+ rescue Exception # rubocop:disable Lint/RescueException
96
+ nil
97
+ end