mbeditor 0.12.9 → 0.13.1

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +216 -1
  3. data/app/assets/javascripts/mbeditor/application.js +6 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +52 -1
  5. data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +104 -103
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
  9. data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
  10. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
  11. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +224 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +704 -270
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +193 -9
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +105 -77
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +434 -112
  18. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +112 -18
  20. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  21. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  22. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  23. data/app/assets/javascripts/mbeditor/search_service.js +50 -4
  24. data/app/assets/javascripts/mbeditor/tab_manager.js +66 -10
  25. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  26. data/app/assets/stylesheets/mbeditor/editor.css +389 -103
  27. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  28. data/app/channels/mbeditor/collaboration_channel.rb +8 -2
  29. data/app/channels/mbeditor/editor_channel.rb +6 -3
  30. data/app/controllers/mbeditor/application_controller.rb +5 -0
  31. data/app/controllers/mbeditor/editors_controller.rb +155 -29
  32. data/app/controllers/mbeditor/git_controller.rb +3 -3
  33. data/app/controllers/mbeditor/logs_controller.rb +3 -1
  34. data/app/services/mbeditor/collaboration_doc_store.rb +49 -3
  35. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  36. data/app/services/mbeditor/editor_state_service.rb +36 -26
  37. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  38. data/app/services/mbeditor/file_operation_service.rb +38 -3
  39. data/app/services/mbeditor/file_tree_service.rb +30 -2
  40. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  41. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  42. data/app/services/mbeditor/git_diff_service.rb +5 -1
  43. data/app/services/mbeditor/git_info_service.rb +20 -8
  44. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  45. data/app/services/mbeditor/git_service.rb +65 -15
  46. data/app/services/mbeditor/js_definition_service.rb +3 -1
  47. data/app/services/mbeditor/js_globals_service.rb +18 -4
  48. data/app/services/mbeditor/js_members_service.rb +3 -2
  49. data/app/services/mbeditor/js_program_service.rb +15 -5
  50. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  51. data/app/services/mbeditor/process_runner.rb +42 -12
  52. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  53. data/app/services/mbeditor/route_service.rb +45 -8
  54. data/app/services/mbeditor/rubocop_run_service.rb +98 -0
  55. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  56. data/app/services/mbeditor/schema_service.rb +73 -66
  57. data/app/services/mbeditor/search_replace_service.rb +42 -7
  58. data/app/services/mbeditor/test_runner_service.rb +45 -10
  59. data/lib/mbeditor/cable_log_filter.rb +8 -2
  60. data/lib/mbeditor/configuration.rb +4 -1
  61. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  62. data/lib/mbeditor/engine.rb +22 -3
  63. data/lib/mbeditor/exception_log.rb +2 -2
  64. data/lib/mbeditor/pending_migrations.rb +33 -0
  65. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  66. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  67. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  68. data/lib/mbeditor/route_map.rb +1 -0
  69. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  70. data/lib/mbeditor/version.rb +1 -1
  71. data/lib/tasks/mbeditor.rake +23 -0
  72. metadata +8 -2
@@ -18,6 +18,8 @@ module Mbeditor
18
18
  def initialize(symbol, workspace_root)
19
19
  @symbol = symbol.to_s
20
20
  @workspace_root = workspace_root.to_s.chomp("/")
21
+ # Compiled once: this is matched against every line of search output.
22
+ @member_regex = /#{Regexp.escape(@symbol)}\.(?:prototype\.)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/
21
23
  end
22
24
 
23
25
  def call
@@ -50,8 +52,7 @@ module Mbeditor
50
52
  snippet = m[3].strip
51
53
  next unless abs_path.start_with?(@workspace_root)
52
54
 
53
- s = Regexp.escape(@symbol)
54
- member_match = snippet.match(/#{s}\.(?:prototype\.)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/)
55
+ member_match = snippet.match(@member_regex)
55
56
  next unless member_match
56
57
 
57
58
  name = member_match[1]
@@ -83,8 +83,8 @@ module Mbeditor
83
83
  skipped = []
84
84
  total = 0
85
85
 
86
- each_candidate(root) do |rel, abs|
87
- if File.size(abs) > MAX_FILE_BYTES
86
+ each_candidate(root) do |rel, abs, stat|
87
+ if stat.size > MAX_FILE_BYTES
88
88
  skipped << { path: rel, reason: "larger than #{MAX_FILE_BYTES} bytes" }
89
89
  next
90
90
  end
@@ -127,12 +127,16 @@ module Mbeditor
127
127
  abs = File.join(dir, name)
128
128
  rel = abs.delete_prefix(root).delete_prefix("/")
129
129
  next if m.excluded?(rel)
130
- next if File.symlink?(abs)
131
130
 
132
- if File.directory?(abs)
131
+ # One lstat answers symlink?, directory? and size — and lstat is
132
+ # exactly right here, since a symlink must not be followed.
133
+ stat = lstat(abs)
134
+ next if stat.nil? || stat.symlink?
135
+
136
+ if stat.directory?
133
137
  stack.push(abs)
134
138
  elsif name.match?(SOURCE_EXT) && !name.match?(MINIFIED_NAME)
135
- yield rel, abs
139
+ yield rel, abs, stat
136
140
  end
137
141
  end
138
142
  end
@@ -144,6 +148,12 @@ module Mbeditor
144
148
  []
145
149
  end
146
150
 
151
+ def lstat(abs)
152
+ File.lstat(abs)
153
+ rescue SystemCallError
154
+ nil
155
+ end
156
+
147
157
  def matcher(root)
148
158
  ExclusionMatcher.new(exclusion_patterns, root: root)
149
159
  end
@@ -218,6 +218,12 @@ module Mbeditor
218
218
  def scope_lint(workspace_root, source)
219
219
  return [] unless available? && Mbeditor.configuration.js_scope_lint != false
220
220
 
221
+ # Gathered before taking MUTEX: both walk the workspace under their own
222
+ # locks, and holding this one across them serialises every JS save
223
+ # behind whichever save is rebuilding the program.
224
+ program = JsProgramService.call(workspace_root.to_s)
225
+ globals = JsGlobalsService.call(workspace_root.to_s)
226
+
221
227
  MUTEX.synchronize do
222
228
  begin
223
229
  ctx = context
@@ -227,7 +233,7 @@ module Mbeditor
227
233
  @lint_helpers_loaded = true
228
234
  return [] unless ctx.eval("typeof __mbLint !== 'undefined'")
229
235
 
230
- names = whitelist(ctx, workspace_root)
236
+ names = whitelist(ctx, program, globals)
231
237
  findings = ctx.eval("__mbLint.lint(#{source.to_json}, #{names.to_json}, #{MAX_SCOPE_FINDINGS})")
232
238
 
233
239
  @checks_run = (@checks_run || 0) + 1
@@ -268,6 +274,13 @@ module Mbeditor
268
274
  end
269
275
 
270
276
  def reset_context!
277
+ # Dropping the reference leaks the V8 isolate until the GC finalizer
278
+ # runs; dispose releases it now.
279
+ begin
280
+ @context&.dispose
281
+ rescue StandardError
282
+ nil
283
+ end
271
284
  @context = nil
272
285
  @checks_run = 0
273
286
  @lint_helpers_loaded = false
@@ -278,12 +291,11 @@ module Mbeditor
278
291
  # plus the browser and React layers. Per-file declaration names are
279
292
  # cached by content digest, so a steady-state save re-parses only the
280
293
  # files that changed since the last lint.
281
- def whitelist(ctx, workspace_root)
294
+ def whitelist(ctx, program, globals)
282
295
  names = []
283
296
  @decl_cache ||= {}
284
297
  live = {}
285
298
 
286
- program = JsProgramService.call(workspace_root.to_s)
287
299
  Array(program[:files]).each do |f|
288
300
  digest = f[:content].hash
289
301
  entry = @decl_cache[f[:path]]
@@ -293,7 +305,6 @@ module Mbeditor
293
305
  end
294
306
  @decl_cache = live
295
307
 
296
- globals = JsGlobalsService.call(workspace_root.to_s)
297
308
  names.concat(Array(globals[:symbols]).map { |s| s[:name].to_s })
298
309
 
299
310
  (names + BROWSER_GLOBALS + REACT_GLOBALS).uniq
@@ -8,7 +8,13 @@ module Mbeditor
8
8
 
9
9
  class TimeoutError < StandardError; end
10
10
 
11
- def call(cmd, timeout: nil, env: {}, stdin_data: nil, chdir: nil)
11
+ CHUNK_BYTES = 64 * 1024
12
+ private_constant :CHUNK_BYTES
13
+
14
+ # +max_bytes+ bounds how much of each stream is kept in memory (nil =
15
+ # unbounded). Anything past the cap is still read and discarded — stopping
16
+ # would block the child on a full pipe and hang the wait below.
17
+ def call(cmd, timeout: nil, env: {}, stdin_data: nil, chdir: nil, max_bytes: nil)
12
18
  out = +""
13
19
  err = +""
14
20
  exit_status = nil
@@ -21,28 +27,52 @@ module Mbeditor
21
27
  stdin.write(stdin_data) if stdin_data
22
28
  stdin.close
23
29
 
24
- timer = if timeout
25
- Thread.new do
26
- sleep timeout
27
- timed_out = true
28
- Process.kill("-KILL", wait_thr.pid)
29
- rescue Errno::ESRCH
30
- nil
30
+ out_thread = Thread.new { out = read_capped(stdout, max_bytes) }
31
+ err_thread = Thread.new { err = read_capped(stderr, max_bytes) }
32
+
33
+ # A deadline join rather than a timer thread: a timer racing normal
34
+ # exit could flag a timeout (and SIGKILL a recycled pid) after the
35
+ # process had already succeeded. The reader threads above keep the
36
+ # pipes drained meanwhile, so this cannot deadlock.
37
+ if timeout
38
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
39
+ timed_out = !wait_thr.join(timeout)
40
+
41
+ # A grandchild that inherited the pipe holds it open after the child
42
+ # exits, so the unbounded joins below could outlive the process
43
+ # itself. Bound them by the same deadline the timer thread enforced.
44
+ remaining = [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
45
+ overrun = !out_thread.join(remaining) || !err_thread.join(remaining)
46
+
47
+ if timed_out || overrun
48
+ begin
49
+ Process.kill("-KILL", wait_thr.pid)
50
+ rescue Errno::ESRCH
51
+ nil
52
+ end
31
53
  end
32
54
  end
33
55
 
34
- out_thread = Thread.new { out = stdout.read }
35
- err_thread = Thread.new { err = stderr.read }
36
56
  out_thread.join
37
57
  err_thread.join
38
-
39
58
  exit_status = wait_thr.value
40
- timer&.kill
41
59
  end
42
60
 
43
61
  raise TimeoutError, "process timed out after #{timeout}s" if timed_out
44
62
 
45
63
  { stdout: out, stderr: err, exit_status: exit_status }
46
64
  end
65
+
66
+ def read_capped(io, max_bytes)
67
+ return io.read.to_s unless max_bytes
68
+
69
+ buf = +""
70
+ while (chunk = io.read(CHUNK_BYTES))
71
+ buf << chunk if buf.bytesize < max_bytes
72
+ end
73
+ # IO#read with a length returns binary; IO#read without one applies the
74
+ # default external encoding, and callers expect the latter.
75
+ buf.force_encoding(Encoding.default_external)
76
+ end
47
77
  end
48
78
  end
@@ -30,6 +30,10 @@ module Mbeditor
30
30
  Struct Set Time File IO Exception Proc Method NilClass
31
31
  ].freeze
32
32
 
33
+ # Keyed by symbol, so a long session accumulates one entry per name ever
34
+ # hovered. Cleared wholesale at the cap — the next lookups just re-run ri.
35
+ MAX_CACHE_ENTRIES = 500
36
+
33
37
  @cache = {}
34
38
  @mutex = Mutex.new
35
39
 
@@ -39,7 +43,10 @@ module Mbeditor
39
43
  return cached unless cached.nil?
40
44
 
41
45
  result = new(symbol).call
42
- @mutex.synchronize { @cache[symbol] = result }
46
+ @mutex.synchronize do
47
+ @cache.clear if @cache.size >= MAX_CACHE_ENTRIES
48
+ @cache[symbol] = result
49
+ end
43
50
  result
44
51
  end
45
52
 
@@ -12,13 +12,6 @@ module Mbeditor
12
12
  module RouteService
13
13
  module_function
14
14
 
15
- # Actions with no route are worth calling out, but only for controllers Rails
16
- # would actually dispatch to. These are the inherited ones every controller
17
- # has and nobody routes.
18
- NON_ACTION_METHODS = %w[
19
- new inspect method_of to_s hash class dup freeze
20
- ].freeze
21
-
22
15
  # "app/controllers/admin/users_controller.rb" -> "admin/users", which is the
23
16
  # key Rails stores in a route's defaults.
24
17
  def controller_key(relative_path)
@@ -29,18 +22,62 @@ module Mbeditor
29
22
  match[1]
30
23
  end
31
24
 
25
+ MUTEX = Mutex.new
26
+ private_constant :MUTEX
27
+
28
+ # Matches the other read-through caches in this engine (FileTreeService 15s,
29
+ # GitInfoService 10s). A write invalidates it outright, so the TTL only ever
30
+ # bounds staleness from a route change made outside the editor.
31
+ CACHE_TTL = 10
32
+
32
33
  # => { "show" => [{ verb:, path:, name: }], ... }
34
+ #
35
+ # Cached because every call walks the host app's ENTIRE route set, and the
36
+ # caller is the inline route hints — which re-request on every activation of
37
+ # a controller tab and on every external content change. Switching between
38
+ # two controllers repeatedly was therefore a full O(routes) scan per switch,
39
+ # and a large app has thousands of routes. The scan itself is unchanged;
40
+ # it just stops happening once per glance.
33
41
  def for_controller(key)
34
42
  return {} if key.nil? || key.empty?
35
43
  return {} unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
36
44
 
37
- routes_for(key)
45
+ cached = cached_routes(key)
46
+ return cached if cached
47
+
48
+ # Built outside the mutex: it runs arbitrary host-app route code, and
49
+ # holding the lock across it would serialise every other controller's
50
+ # lookup behind the slowest one.
51
+ computed = routes_for(key)
52
+ store_routes(key, computed)
53
+ computed
38
54
  rescue StandardError
39
55
  # A broken route set must not take the editor down with it — the file still
40
56
  # opens, just without hints.
41
57
  {}
42
58
  end
43
59
 
60
+ def invalidate
61
+ MUTEX.synchronize { @cache = {} }
62
+ nil
63
+ end
64
+
65
+ def cached_routes(key)
66
+ MUTEX.synchronize do
67
+ entry = (@cache ||= {})[key]
68
+ return entry[:data] if entry && (Process.clock_gettime(Process::CLOCK_MONOTONIC) - entry[:ts]) < CACHE_TTL
69
+ end
70
+ nil
71
+ end
72
+ private_class_method :cached_routes
73
+
74
+ def store_routes(key, data)
75
+ MUTEX.synchronize do
76
+ (@cache ||= {})[key] = { ts: Process.clock_gettime(Process::CLOCK_MONOTONIC), data: data }
77
+ end
78
+ end
79
+ private_class_method :store_routes
80
+
44
81
  def routes_for(key)
45
82
  out = {}
46
83
  Rails.application.routes.routes.each do |route|
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tmpdir"
5
+
6
+ module Mbeditor
7
+ # Whole-workspace `rubocop` run — the counterpart to the per-buffer /lint
8
+ # endpoint, which can only report on files Monaco already has open.
9
+ #
10
+ # `-a` (safe autocorrect) rather than `-A`: unsafe corrections can change
11
+ # behaviour, and this runs over every file in the project at once, so an
12
+ # unreviewed `-A` is a much bigger blast radius than the per-buffer quick fix.
13
+ module RubocopRunService
14
+ module_function
15
+
16
+ def run(workspace_root, mode: :check, timeout: 300)
17
+ matcher = ExclusionMatcher.new(Mbeditor.configuration.excluded_paths, root: workspace_root)
18
+ cmd = AvailabilityProbe.rubocop_command(workspace_root) +
19
+ [AvailabilityProbe.rubocop_server_flag(workspace_root), "--format", "json", "--no-color"]
20
+ cmd << "-a" if mode.to_sym == :autocorrect
21
+
22
+ result = ProcessRunner.call(
23
+ cmd,
24
+ timeout: timeout,
25
+ chdir: workspace_root.to_s,
26
+ env: { "RUBOCOP_CACHE_ROOT" => File.join(Dir.tmpdir, "rubocop") }
27
+ )
28
+ parse(result[:stdout], result[:stderr], matcher, mode: mode)
29
+ rescue ProcessRunner::TimeoutError
30
+ error("RuboCop timed out after #{timeout}s")
31
+ rescue StandardError => e
32
+ error(e.message)
33
+ end
34
+
35
+ # RuboCop writes its JSON to stdout, but anything the host app prints while
36
+ # loading (a deprecation warning, a bundler notice) lands in front of it —
37
+ # hence the seek to the first brace, same as the /lint path.
38
+ # +matcher+ drops offenses in paths the workspace excludes — vendored gems
39
+ # above all. RuboCop's own defaults exclude `vendor/**/*`, but a host
40
+ # .rubocop.yml that sets AllCops/Exclude without `inherit_mode: merge`
41
+ # silently replaces them, and the panel then fills with gem source.
42
+ #
43
+ # ponytail: filtered after the fact, so a host in that state still pays for
44
+ # the walk. Generate a config that inherits from theirs and re-adds the
45
+ # excludes if the run time ever becomes the complaint.
46
+ #
47
+ # `-a` reports what it *fixed* alongside what it left: a corrected offense
48
+ # still appears in the JSON, with `corrected: true` and `correctable: true`.
49
+ # Rendering those made the Fix button look inert — the list after a run was
50
+ # byte-identical to the list before it, and the correctable tally never
51
+ # moved, so the button stayed lit and every further click was a slow no-op.
52
+ # They are dropped here rather than at the panel because /rubocop's response
53
+ # is documented as the post-correction offense list.
54
+ def parse(stdout, stderr = nil, matcher = nil, mode: :check)
55
+ idx = stdout.index("{")
56
+ return error(stderr.to_s.strip.split("\n").last || "RuboCop produced no output") unless idx
57
+
58
+ data = JSON.parse(stdout[idx..])
59
+ files = (data["files"] || []).filter_map do |file|
60
+ next if matcher&.excluded?(file["path"].to_s)
61
+
62
+ offenses = (file["offenses"] || []).reject { |o| o["corrected"] == true }
63
+ next if offenses.empty?
64
+
65
+ { path: file["path"], offenses: offenses.map { |o| offense(o) } }
66
+ end
67
+
68
+ {
69
+ ok: true,
70
+ files: files.sort_by { |f| f[:path].to_s },
71
+ summary: data["summary"] || {},
72
+ # RuboCop's `correctable` means "a cop could rewrite this", safely or
73
+ # not. `-a` already looped to convergence, so whatever survived it needs
74
+ # `-A` — which this whole-workspace path deliberately never runs. Left
75
+ # as the raw tally, the Fix button never disabled: Style/
76
+ # FrozenStringLiteralComment and friends are correctable forever.
77
+ correctable: mode.to_sym == :autocorrect ? 0 : files.sum { |f| f[:offenses].count { |o| o[:correctable] } }
78
+ }
79
+ rescue JSON::ParserError => e
80
+ error(e.message)
81
+ end
82
+
83
+ def offense(o)
84
+ {
85
+ copName: o["cop_name"],
86
+ message: o["message"],
87
+ severity: o["severity"],
88
+ correctable: o["correctable"] == true,
89
+ line: o.dig("location", "start_line") || o.dig("location", "line") || 1,
90
+ column: o.dig("location", "start_column") || o.dig("location", "column") || 1
91
+ }
92
+ end
93
+
94
+ def error(message)
95
+ { ok: false, error: message, files: [], summary: {}, correctable: 0 }
96
+ end
97
+ end
98
+ end
@@ -34,6 +34,8 @@ module Mbeditor
34
34
  # Upper bound on cached files. Entries carry full file contents, so this
35
35
  # caps both the on-disk JSON and the resident hash.
36
36
  MAX_CACHE_ENTRIES = 2_000
37
+ # Minimum gap between disk writes of that cache (seconds).
38
+ PERSIST_INTERVAL = 30
37
39
 
38
40
  # In-process file-index cache.
39
41
  # Structure: { absolute_path => {
@@ -63,6 +65,7 @@ module Mbeditor
63
65
  def clear_cache!
64
66
  @mutex.synchronize { @file_cache.clear; @cache_loaded = false }
65
67
  @last_evict_at = nil
68
+ @last_persist_at = nil
66
69
  path = @cache_path.to_s
67
70
  File.delete(path) if !path.empty? && File.exist?(path)
68
71
  rescue StandardError
@@ -154,10 +157,18 @@ module Mbeditor
154
157
  end
155
158
 
156
159
  # Atomically write the in-memory cache to disk (tmp-file + rename).
160
+ # Debounced: the snapshot carries every cached file's full source, so a
161
+ # lookup that parsed one new file would otherwise rewrite tens of MB of
162
+ # JSON. Skipping a write only delays it — the entries stay in memory and
163
+ # a later lookup flushes them.
157
164
  def persist_cache
158
165
  path = @cache_path.to_s
159
166
  return if path.empty?
160
167
 
168
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
169
+ return if @last_persist_at && (now - @last_persist_at) < PERSIST_INTERVAL
170
+
171
+ @last_persist_at = now
161
172
  snapshot = @mutex.synchronize do
162
173
  # Each entry holds the file's full source lines, so an uncapped cache
163
174
  # grows to tens of MB on a large workspace and is rewritten in full on
@@ -263,7 +274,10 @@ module Mbeditor
263
274
  signature: (cached[:lines][def_line - 1] || "").strip,
264
275
  comments: extract_comments(cached[:lines], def_line)
265
276
  }
266
- return results if results.length >= MAX_RESULTS
277
+ if results.length >= MAX_RESULTS
278
+ persist_cache if @new_entries
279
+ return results
280
+ end
267
281
  end
268
282
  rescue StandardError
269
283
  # Malformed file or unreadable; skip silently
@@ -284,14 +298,19 @@ module Mbeditor
284
298
 
285
299
  def evict_deleted_cache_entries
286
300
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
287
- stale_keys = @shared_mutex.synchronize do
301
+ keys = @shared_mutex.synchronize do
288
302
  last = self.class.instance_variable_get(:@last_evict_at)
289
303
  next nil if last && (now - last) < EVICT_INTERVAL
290
304
 
291
305
  self.class.instance_variable_set(:@last_evict_at, now)
292
- @shared_cache.keys.select { |p| !File.exist?(p) }
306
+ @shared_cache.keys
293
307
  end
294
- return if stale_keys.nil? || stale_keys.empty?
308
+ return if keys.nil?
309
+
310
+ # stat()ing outside the lock: up to MAX_CACHE_ENTRIES syscalls, and every
311
+ # other cache reader would otherwise wait on them.
312
+ stale_keys = keys.reject { |p| File.exist?(p) }
313
+ return if stale_keys.empty?
295
314
 
296
315
  @shared_mutex.synchronize { stale_keys.each { |k| @shared_cache.delete(k) } }
297
316
  @new_entries = true
@@ -44,10 +44,16 @@ module Mbeditor
44
44
  def derive_table_name(model_name)
45
45
  normalized = model_name.delete(" ")
46
46
 
47
- # Check model file for an explicit table_name override
47
+ # Check model file for an explicit table_name override.
48
+ #
49
+ # Inflector.underscore is not a sanitizer — it leaves "../" untouched, so
50
+ # a model name reaches this join as a path fragment. Callers are expected
51
+ # to have validated the name, but the containment check is repeated here
52
+ # so the service is safe on its own; it also covers a symlink under
53
+ # app/models pointing outside the workspace, which no name check can.
48
54
  singular = ActiveSupport::Inflector.underscore(normalized)
49
55
  model_file = File.join(@workspace_root, "app", "models", "#{singular}.rb")
50
- if File.exist?(model_file)
56
+ if SafePath.within?(@workspace_root, model_file) && File.exist?(model_file)
51
57
  begin
52
58
  source = File.read(model_file, encoding: "utf-8")
53
59
  # Matches: self.table_name = "name" or = :name or = 'name'
@@ -235,10 +241,11 @@ module Mbeditor
235
241
  def parse_sql_indexes(content, table_name)
236
242
  indexes = []
237
243
 
238
- # Match CREATE INDEX ... ON table_name (columns)
239
- pattern = /CREATE\s+(?:UNIQUE\s+)?INDEX\s+["`]?(\w+)["`]?\s+ON\s+(?:public\.)?["`]?#{Regexp.escape(table_name)}["`]?\s*\((.*?)\)/mi
244
+ # Match CREATE INDEX ... ON table_name (columns). UNIQUE is captured here
245
+ # rather than re-scanned per index with a freshly compiled regex.
246
+ pattern = /CREATE\s+(UNIQUE\s+)?INDEX\s+["`]?(\w+)["`]?\s+ON\s+(?:public\.)?["`]?#{Regexp.escape(table_name)}["`]?\s*\((.*?)\)/mi
240
247
 
241
- content.scan(pattern) do |index_name, columns_str|
248
+ content.scan(pattern) do |unique, index_name, columns_str|
242
249
  cols = columns_str.split(',').map { |c| c.strip.gsub(/["`]/, '').split(/\s+/).first }.compact
243
250
  next if cols.empty?
244
251
 
@@ -246,7 +253,7 @@ module Mbeditor
246
253
  name: index_name,
247
254
  columns: cols
248
255
  }
249
- idx[:unique] = true if content.match?(/CREATE\s+UNIQUE\s+INDEX\s+["`]?#{Regexp.escape(index_name)}/mi)
256
+ idx[:unique] = true if unique
250
257
 
251
258
  indexes << idx
252
259
  end
@@ -255,67 +262,67 @@ module Mbeditor
255
262
  end
256
263
 
257
264
  # Map SQL types to Rails column types
258
- def sql_type_to_rails(sql_type)
259
- type_map = {
260
- 'integer' => 'integer',
261
- 'int' => 'integer',
262
- 'int4' => 'integer',
263
- 'int2' => 'integer',
264
- 'int8' => 'bigint',
265
- 'bigint' => 'bigint',
266
- 'smallint' => 'integer',
267
- 'bigserial' => 'bigint',
268
- 'serial' => 'integer',
269
- 'varchar' => 'string',
270
- 'character varying' => 'string',
271
- 'character' => 'string',
272
- 'char' => 'string',
273
- 'text' => 'text',
274
- 'citext' => 'string',
275
- 'boolean' => 'boolean',
276
- 'bool' => 'boolean',
277
- 'decimal' => 'decimal',
278
- 'numeric' => 'decimal',
279
- 'real' => 'float',
280
- 'float' => 'float',
281
- 'float4' => 'float',
282
- 'float8' => 'float',
283
- 'double precision' => 'float',
284
- 'double' => 'float',
285
- 'money' => 'decimal',
286
- 'timestamp' => 'datetime',
287
- 'timestamp without time zone' => 'datetime',
288
- 'timestamp with time zone' => 'datetime',
289
- 'timestamptz' => 'datetime',
290
- 'datetime' => 'datetime',
291
- 'date' => 'date',
292
- 'time' => 'time',
293
- 'time without time zone' => 'time',
294
- 'time with time zone' => 'time',
295
- 'interval' => 'string',
296
- 'json' => 'json',
297
- 'jsonb' => 'jsonb',
298
- 'uuid' => 'uuid',
299
- 'bytea' => 'binary',
300
- 'bit' => 'string',
301
- 'bit varying' => 'string',
302
- 'inet' => 'string',
303
- 'cidr' => 'string',
304
- 'macaddr' => 'string',
305
- 'xml' => 'string',
306
- 'hstore' => 'hstore',
307
- 'tsvector' => 'string',
308
- 'ltree' => 'string',
309
- 'point' => 'string',
310
- 'line' => 'string',
311
- 'lseg' => 'string',
312
- 'box' => 'string',
313
- 'path' => 'string',
314
- 'polygon' => 'string',
315
- 'circle' => 'string'
316
- }
265
+ SQL_TYPE_TO_RAILS = {
266
+ 'integer' => 'integer',
267
+ 'int' => 'integer',
268
+ 'int4' => 'integer',
269
+ 'int2' => 'integer',
270
+ 'int8' => 'bigint',
271
+ 'bigint' => 'bigint',
272
+ 'smallint' => 'integer',
273
+ 'bigserial' => 'bigint',
274
+ 'serial' => 'integer',
275
+ 'varchar' => 'string',
276
+ 'character varying' => 'string',
277
+ 'character' => 'string',
278
+ 'char' => 'string',
279
+ 'text' => 'text',
280
+ 'citext' => 'string',
281
+ 'boolean' => 'boolean',
282
+ 'bool' => 'boolean',
283
+ 'decimal' => 'decimal',
284
+ 'numeric' => 'decimal',
285
+ 'real' => 'float',
286
+ 'float' => 'float',
287
+ 'float4' => 'float',
288
+ 'float8' => 'float',
289
+ 'double precision' => 'float',
290
+ 'double' => 'float',
291
+ 'money' => 'decimal',
292
+ 'timestamp' => 'datetime',
293
+ 'timestamp without time zone' => 'datetime',
294
+ 'timestamp with time zone' => 'datetime',
295
+ 'timestamptz' => 'datetime',
296
+ 'datetime' => 'datetime',
297
+ 'date' => 'date',
298
+ 'time' => 'time',
299
+ 'time without time zone' => 'time',
300
+ 'time with time zone' => 'time',
301
+ 'interval' => 'string',
302
+ 'json' => 'json',
303
+ 'jsonb' => 'jsonb',
304
+ 'uuid' => 'uuid',
305
+ 'bytea' => 'binary',
306
+ 'bit' => 'string',
307
+ 'bit varying' => 'string',
308
+ 'inet' => 'string',
309
+ 'cidr' => 'string',
310
+ 'macaddr' => 'string',
311
+ 'xml' => 'string',
312
+ 'hstore' => 'hstore',
313
+ 'tsvector' => 'string',
314
+ 'ltree' => 'string',
315
+ 'point' => 'string',
316
+ 'line' => 'string',
317
+ 'lseg' => 'string',
318
+ 'box' => 'string',
319
+ 'path' => 'string',
320
+ 'polygon' => 'string',
321
+ 'circle' => 'string'
322
+ }.freeze
317
323
 
318
- type_map[sql_type.downcase] || sql_type
324
+ def sql_type_to_rails(sql_type)
325
+ SQL_TYPE_TO_RAILS[sql_type.downcase] || sql_type
319
326
  end
320
327
  end
321
328
  end