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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +216 -1
- data/app/assets/javascripts/mbeditor/application.js +6 -1
- data/app/assets/javascripts/mbeditor/collaboration_service.js +52 -1
- 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 +104 -103
- 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 +224 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +704 -270
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +193 -9
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +105 -77
- data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +434 -112
- data/app/assets/javascripts/mbeditor/file_import.js +78 -0
- data/app/assets/javascripts/mbeditor/file_service.js +112 -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 +50 -4
- data/app/assets/javascripts/mbeditor/tab_manager.js +66 -10
- data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
- data/app/assets/stylesheets/mbeditor/editor.css +389 -103
- data/app/channels/mbeditor/channel_authentication.rb +7 -0
- data/app/channels/mbeditor/collaboration_channel.rb +8 -2
- 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 +155 -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 +49 -3
- data/app/services/mbeditor/duplicate_content_scanner.rb +105 -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 +18 -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 +98 -0
- data/app/services/mbeditor/ruby_definition_service.rb +23 -4
- data/app/services/mbeditor/schema_service.rb +73 -66
- data/app/services/mbeditor/search_replace_service.rb +42 -7
- data/app/services/mbeditor/test_runner_service.rb +45 -10
- data/lib/mbeditor/cable_log_filter.rb +8 -2
- data/lib/mbeditor/configuration.rb +4 -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 +1 -0
- data/lib/mbeditor/ruby_lsp_client.rb +71 -12
- data/lib/mbeditor/version.rb +1 -1
- data/lib/tasks/mbeditor.rake +23 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|