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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +150 -0
- data/app/assets/javascripts/mbeditor/application.js +7 -1
- data/app/assets/javascripts/mbeditor/collaboration_service.js +31 -0
- data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +102 -25
- data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
- data/app/assets/javascripts/mbeditor/components/ImportDialog.js +216 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +598 -130
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +162 -6
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +39 -33
- data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
- data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +312 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +427 -111
- data/app/assets/javascripts/mbeditor/file_import.js +78 -0
- data/app/assets/javascripts/mbeditor/file_service.js +122 -18
- data/app/assets/javascripts/mbeditor/git_service.js +130 -8
- data/app/assets/javascripts/mbeditor/history_service.js +33 -38
- data/app/assets/javascripts/mbeditor/log_service.js +4 -0
- data/app/assets/javascripts/mbeditor/search_service.js +30 -2
- data/app/assets/javascripts/mbeditor/tab_manager.js +59 -6
- data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
- data/app/assets/stylesheets/mbeditor/editor.css +268 -53
- data/app/channels/mbeditor/channel_authentication.rb +7 -0
- data/app/channels/mbeditor/editor_channel.rb +6 -3
- data/app/controllers/mbeditor/application_controller.rb +5 -0
- data/app/controllers/mbeditor/editors_controller.rb +173 -29
- data/app/controllers/mbeditor/git_controller.rb +3 -3
- data/app/controllers/mbeditor/logs_controller.rb +3 -1
- data/app/services/mbeditor/collaboration_doc_store.rb +7 -0
- data/app/services/mbeditor/editor_state_service.rb +36 -26
- data/app/services/mbeditor/exclusion_matcher.rb +12 -10
- data/app/services/mbeditor/file_operation_service.rb +38 -3
- data/app/services/mbeditor/file_tree_service.rb +30 -2
- data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
- data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
- data/app/services/mbeditor/git_diff_service.rb +5 -1
- data/app/services/mbeditor/git_info_service.rb +20 -8
- data/app/services/mbeditor/git_line_diff_service.rb +6 -2
- data/app/services/mbeditor/git_service.rb +65 -15
- data/app/services/mbeditor/js_definition_service.rb +3 -1
- data/app/services/mbeditor/js_globals_service.rb +7 -4
- data/app/services/mbeditor/js_members_service.rb +3 -2
- data/app/services/mbeditor/js_program_service.rb +15 -5
- data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
- data/app/services/mbeditor/process_runner.rb +42 -12
- data/app/services/mbeditor/ri_definition_service.rb +8 -1
- data/app/services/mbeditor/route_service.rb +45 -8
- data/app/services/mbeditor/rubocop_run_service.rb +86 -0
- data/app/services/mbeditor/ruby_definition_service.rb +23 -4
- data/app/services/mbeditor/schema_service.rb +65 -64
- data/app/services/mbeditor/search_replace_service.rb +31 -5
- data/app/services/mbeditor/test_runner_service.rb +98 -10
- data/lib/mbeditor/cable_log_filter.rb +8 -2
- data/lib/mbeditor/configuration.rb +12 -1
- data/lib/mbeditor/editor_bootstrap.rb +18 -12
- data/lib/mbeditor/engine.rb +22 -3
- data/lib/mbeditor/exception_log.rb +2 -2
- data/lib/mbeditor/pending_migrations.rb +33 -0
- data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
- data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
- data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
- data/lib/mbeditor/route_map.rb +2 -0
- data/lib/mbeditor/ruby_lsp_client.rb +71 -12
- data/lib/mbeditor/version.rb +1 -1
- metadata +7 -2
|
@@ -8,7 +8,13 @@ module Mbeditor
|
|
|
8
8
|
|
|
9
9
|
class TimeoutError < StandardError; end
|
|
10
10
|
|
|
11
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
306
|
+
@shared_cache.keys
|
|
293
307
|
end
|
|
294
|
-
return if
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
472
|
+
source = if use_regex
|
|
473
|
+
whole_word ? "\\b(?:#{query})\\b" : query
|
|
451
474
|
else
|
|
452
|
-
|
|
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)
|