mbeditor 0.12.9 → 0.13.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -0
  3. data/app/assets/javascripts/mbeditor/application.js +7 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +31 -0
  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 +102 -25
  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 +216 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +598 -130
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +162 -6
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +39 -33
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +312 -0
  18. data/app/assets/javascripts/mbeditor/editor_plugins.js +427 -111
  19. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  20. data/app/assets/javascripts/mbeditor/file_service.js +122 -18
  21. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  22. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  23. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  24. data/app/assets/javascripts/mbeditor/search_service.js +30 -2
  25. data/app/assets/javascripts/mbeditor/tab_manager.js +59 -6
  26. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  27. data/app/assets/stylesheets/mbeditor/editor.css +268 -53
  28. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  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 +173 -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 +7 -0
  35. data/app/services/mbeditor/editor_state_service.rb +36 -26
  36. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  37. data/app/services/mbeditor/file_operation_service.rb +38 -3
  38. data/app/services/mbeditor/file_tree_service.rb +30 -2
  39. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  40. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  41. data/app/services/mbeditor/git_diff_service.rb +5 -1
  42. data/app/services/mbeditor/git_info_service.rb +20 -8
  43. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  44. data/app/services/mbeditor/git_service.rb +65 -15
  45. data/app/services/mbeditor/js_definition_service.rb +3 -1
  46. data/app/services/mbeditor/js_globals_service.rb +7 -4
  47. data/app/services/mbeditor/js_members_service.rb +3 -2
  48. data/app/services/mbeditor/js_program_service.rb +15 -5
  49. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  50. data/app/services/mbeditor/process_runner.rb +42 -12
  51. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  52. data/app/services/mbeditor/route_service.rb +45 -8
  53. data/app/services/mbeditor/rubocop_run_service.rb +86 -0
  54. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  55. data/app/services/mbeditor/schema_service.rb +65 -64
  56. data/app/services/mbeditor/search_replace_service.rb +31 -5
  57. data/app/services/mbeditor/test_runner_service.rb +98 -10
  58. data/lib/mbeditor/cable_log_filter.rb +8 -2
  59. data/lib/mbeditor/configuration.rb +12 -1
  60. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  61. data/lib/mbeditor/engine.rb +22 -3
  62. data/lib/mbeditor/exception_log.rb +2 -2
  63. data/lib/mbeditor/pending_migrations.rb +33 -0
  64. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  65. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  66. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  67. data/lib/mbeditor/route_map.rb +2 -0
  68. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  69. data/lib/mbeditor/version.rb +1 -1
  70. metadata +7 -2
@@ -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,86 @@
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)
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
+ def parse(stdout, stderr = nil, matcher = nil)
47
+ idx = stdout.index("{")
48
+ return error(stderr.to_s.strip.split("\n").last || "RuboCop produced no output") unless idx
49
+
50
+ data = JSON.parse(stdout[idx..])
51
+ files = (data["files"] || []).filter_map do |file|
52
+ next if matcher&.excluded?(file["path"].to_s)
53
+
54
+ offenses = file["offenses"] || []
55
+ next if offenses.empty?
56
+
57
+ { path: file["path"], offenses: offenses.map { |o| offense(o) } }
58
+ end
59
+
60
+ {
61
+ ok: true,
62
+ files: files.sort_by { |f| f[:path].to_s },
63
+ summary: data["summary"] || {},
64
+ correctable: files.sum { |f| f[:offenses].count { |o| o[:correctable] } }
65
+ }
66
+ rescue JSON::ParserError => e
67
+ error(e.message)
68
+ end
69
+
70
+ def offense(o)
71
+ {
72
+ copName: o["cop_name"],
73
+ message: o["message"],
74
+ severity: o["severity"],
75
+ correctable: o["correctable"] == true,
76
+ corrected: o["corrected"] == true,
77
+ line: o.dig("location", "start_line") || o.dig("location", "line") || 1,
78
+ column: o.dig("location", "start_column") || o.dig("location", "column") || 1
79
+ }
80
+ end
81
+
82
+ def error(message)
83
+ { ok: false, error: message, files: [], summary: {}, correctable: 0 }
84
+ end
85
+ end
86
+ 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
@@ -235,10 +235,11 @@ module Mbeditor
235
235
  def parse_sql_indexes(content, table_name)
236
236
  indexes = []
237
237
 
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
238
+ # Match CREATE INDEX ... ON table_name (columns). UNIQUE is captured here
239
+ # rather than re-scanned per index with a freshly compiled regex.
240
+ pattern = /CREATE\s+(UNIQUE\s+)?INDEX\s+["`]?(\w+)["`]?\s+ON\s+(?:public\.)?["`]?#{Regexp.escape(table_name)}["`]?\s*\((.*?)\)/mi
240
241
 
241
- content.scan(pattern) do |index_name, columns_str|
242
+ content.scan(pattern) do |unique, index_name, columns_str|
242
243
  cols = columns_str.split(',').map { |c| c.strip.gsub(/["`]/, '').split(/\s+/).first }.compact
243
244
  next if cols.empty?
244
245
 
@@ -246,7 +247,7 @@ module Mbeditor
246
247
  name: index_name,
247
248
  columns: cols
248
249
  }
249
- idx[:unique] = true if content.match?(/CREATE\s+UNIQUE\s+INDEX\s+["`]?#{Regexp.escape(index_name)}/mi)
250
+ idx[:unique] = true if unique
250
251
 
251
252
  indexes << idx
252
253
  end
@@ -255,67 +256,67 @@ module Mbeditor
255
256
  end
256
257
 
257
258
  # 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
- }
259
+ SQL_TYPE_TO_RAILS = {
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
+ }.freeze
317
317
 
318
- type_map[sql_type.downcase] || sql_type
318
+ def sql_type_to_rails(sql_type)
319
+ SQL_TYPE_TO_RAILS[sql_type.downcase] || sql_type
319
320
  end
320
321
  end
321
322
  end
@@ -23,6 +23,12 @@ module Mbeditor
23
23
  MAX_RESULTS = 10_000
24
24
  RESULT_CACHE_TTL = 30 # seconds; also invalidated on mbeditor file mutations
25
25
  RESULT_CACHE_MAX_ENTRIES = 3
26
+ # A user-supplied regex is matched in-process, and Timeout.timeout cannot
27
+ # interrupt a CRuby regex — only Regexp's own timeout (Ruby >= 3.2) can.
28
+ # Below that the gemspec floor (3.0) leaves a catastrophic pattern
29
+ # unguarded, which is the pre-existing behaviour.
30
+ REGEXP_TIMEOUT = 1 # seconds
31
+ REGEXP_TIMEOUT_SUPPORTED = Regexp.method_defined?(:timeout)
26
32
 
27
33
  STATE_MUTEX = Mutex.new
28
34
  private_constant :STATE_MUTEX
@@ -112,6 +118,10 @@ module Mbeditor
112
118
  else
113
119
  @result_cache = {}
114
120
  end
121
+ # Bumped so a scan already in flight can tell its results are stale
122
+ # and skip filling the cache it just emptied. One counter for every
123
+ # root: an unrelated invalidation only costs a cache miss.
124
+ @cache_generation = (@cache_generation || 0) + 1
115
125
  end
116
126
  end
117
127
 
@@ -153,7 +163,14 @@ module Mbeditor
153
163
  content = File.binread(full_path).force_encoding("UTF-8")
154
164
  .encode("UTF-8", invalid: :replace, undef: :replace)
155
165
  replacements_in_file = content.scan(pattern).length
156
- new_content = content.gsub(pattern, replacement)
166
+ # Block form for a literal search: the two-argument gsub expands
167
+ # \1, \& and \\ in the replacement, which a non-regex replace
168
+ # must insert verbatim.
169
+ new_content = if use_regex
170
+ content.gsub(pattern, replacement)
171
+ else
172
+ content.gsub(pattern) { replacement }
173
+ end
157
174
  if new_content != content
158
175
  File.binwrite(full_path, new_content.encode("UTF-8", invalid: :replace, undef: :replace))
159
176
  files_affected << rel_path
@@ -185,10 +202,12 @@ module Mbeditor
185
202
  key = [workspace_root.to_s, query, use_regex, match_case, whole_word,
186
203
  Array(excluded_paths).map(&:to_s).sort]
187
204
  now = monotonic
188
- STATE_MUTEX.synchronize do
205
+ generation = STATE_MUTEX.synchronize do
189
206
  @result_cache ||= {}
190
207
  entry = @result_cache[key]
191
208
  return entry[:data] if entry && (now - entry[:ts]) < RESULT_CACHE_TTL
209
+
210
+ @cache_generation ||= 0
192
211
  end
193
212
 
194
213
  data = scan(workspace_root, query, use_regex: use_regex, match_case: match_case,
@@ -198,6 +217,10 @@ module Mbeditor
198
217
  # Don't cache superseded scans — their results are truncated by the kill.
199
218
  unless data[:superseded]
200
219
  STATE_MUTEX.synchronize do
220
+ # A save landed while this scan ran: these rows predate it, and
221
+ # storing them now would serve pre-save matches for a full TTL.
222
+ next if (@cache_generation ||= 0) != generation
223
+
201
224
  @result_cache[key] = { ts: monotonic, data: data }
202
225
  @result_cache.delete(@result_cache.keys.first) while @result_cache.length > RESULT_CACHE_MAX_ENTRIES
203
226
  end
@@ -446,11 +469,14 @@ module Mbeditor
446
469
 
447
470
  def build_pattern(query, use_regex:, match_case:, whole_word:)
448
471
  flags = match_case ? 0 : Regexp::IGNORECASE
449
- if use_regex
450
- Regexp.new(whole_word ? "\\b(?:#{query})\\b" : query, flags)
472
+ source = if use_regex
473
+ whole_word ? "\\b(?:#{query})\\b" : query
451
474
  else
452
- Regexp.new(whole_word ? "\\b#{Regexp.escape(query)}\\b" : Regexp.escape(query), flags)
475
+ whole_word ? "\\b#{Regexp.escape(query)}\\b" : Regexp.escape(query)
453
476
  end
477
+ return Regexp.new(source, flags, timeout: REGEXP_TIMEOUT) if REGEXP_TIMEOUT_SUPPORTED
478
+
479
+ Regexp.new(source, flags)
454
480
  end
455
481
 
456
482
  def relative_path(absolute_path, workspace_root)